Repository navigation
persist: read multipart blobs from stores without x-amz-mp-parts-count - #39115
Merged
Merged
Conversation
jubrad
force-pushed
the
jubrad/persist-multipart-parts-count
branch
from
September 24, 2026 21:23
fb945a5 to
66bfa62
Compare
`S3Blob::get` reads a multipart-uploaded object part by part, using the `x-amz-mp-parts-count` header on its first request to learn how many parts to fetch. A store that does not return that header is indistinguishable from one holding a single-part object, so persist fetched only the first part and handed the decoder a truncated blob, which panicked as `Invalid Parquet file. Corrupt footer`. That is not hypothetical. rustfs answers `GetObject?partNumber=1` with 206 and a correct `Content-Range` but no parts count, and its `HeadObject` ignores `partNumber` entirely. Against it, every cold read of a blob above the 8MiB multipart threshold, which is every large compaction output, crashed environmentd. The blob cache masks it for freshly written blobs, so it surfaces only on a cold read. minio, garage, seaweedfs and S3 all return the header. `get` now also takes the object's total size from `Content-Range`, which every store returns on a request that names a part. When the part count is missing and the first part is short of the total, the remainder is fetched by byte range in chunks the size of the first part. The reassembled length is then checked against the total, so a store that returns a short object fails the read, which persist retries, rather than corrupting data downstream. Where the header is present, nothing changes. The length check is worth keeping on its own account: it turns silent truncation into a loud failure for any S3-compatible store. It does rest on one assumption, now recorded at the parse site: the denominator in `Content-Range` is the length of the whole object, not of the part that was requested. That is what s3 specifies and what minio, garage, seaweedfs and rustfs all return, but a store reporting the part's length there would fail every multipart get rather than silently truncate. Tests: unit tests for the `Content-Range` parser and the byte-range chunking. Verified end to end against rustfs, where the scenario that reproducibly crashed environmentd now completes about 20GiB of cold reads without failures. Release note: Materialize now reads multipart objects correctly from S3-compatible blob stores that do not return the `x-amz-mp-parts-count` response header. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
jubrad
force-pushed
the
jubrad/persist-multipart-parts-count
branch
from
September 24, 2026 21:29
66bfa62 to
91eb807
Compare
jubrad
marked this pull request as ready for review
September 25, 2026 13:26
ggevay
added a commit
that referenced
this pull request
Sep 25, 2026
Follow-up to #39125 ([DEP-268](https://linear.app/materializeinc/issue/DEP-268)). **Issue:** - Released versions of persist cannot read multipart blobs from RustFS, because RustFS omits `x-amz-mp-parts-count`. #39115 fixes this, but no release has it yet. Following the current local kind guide on v26.42.0, any batch part over 8 MiB crashes environmentd or crash-loops a cluster with `Invalid Parquet file. Corrupt footer`. - `misc/helm-charts/testing/minio.yaml` pulls `quay.io/minio/minio`, which no longer allows anonymous pulls, so the orchestratord nightly workflows cannot start MinIO. **Solution:** - `testing/minio.yaml` runs `materialize/minio:v26.42.0`, our own MinIO build, which is published under every release tag. The image has no `mc`, so a Job creates the buckets instead of the postStart hook. The service name, credentials, and buckets are unchanged, so the `persist_backend_url` in released `materialize.yaml` files keeps working. - The guide fetches `minio.yaml` from `main` and no longer asks users to edit `persist_backend_url`. - `workflow_documentation_defaults` follows the guide again. **Gotchas:** - `rustfs.yaml` stays in `misc/helm-charts/testing` for the RustFS move once a release contains #39115. - The orchestratord workflows run only in Nightly, so they need a Nightly trigger on this PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
ublubu
approved these changes
Sep 27, 2026
ublubu
left a comment
Contributor
There was a problem hiding this comment.
LGTM.
Happy to wait until the benchmarking stuff goes in to have an automated way to test this on rustfs.
Member
Author
|
@ublubu I did test through some rough draft benchmark code here #38800 and here MaterializeInc/materialize-terraform-self-managed#485 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Issue
S3Blob::getreads a multipart-uploaded object part by part. It learns how many parts to fetch from thex-amz-mp-parts-countheader on its first request, and treats a missing header as "this object has one part". A store that does not send that header therefore returns only the object's first 8 MiB, and persist hands the truncated bytes to the parquet decoder, which panics withInvalid Parquet file. Corrupt footer.That is not hypothetical. rustfs answers
GetObject?partNumber=1with 206 and a correctContent-Rangebut no parts count, and itsHeadObjectignorespartNumberentirely. Running persist against it, every cold read of a blob above the 8 MiB multipart threshold, which is every large compaction output, crashed environmentd. The blob cache masks it for freshly written blobs, so it only surfaces on a cold read, for example after a restart or once the cache is under pressure.minio, garage, seaweedfs and S3 all return the header, so this does not affect any store we run today. I found it while benchmarking blob stores as possible minio replacements.
Solution
getnow also takes the object's total size fromContent-Range, which every store returns on a request that names a part. When the part count is missing and the first part is short of the total, the remainder is fetched by byte range, in chunks the size of the first part. The reassembled length is then checked against the total, so a store returning a short object fails the read, which persist retries, rather than corrupting data downstream.Where the header is present, which is every store we currently use, nothing changes: same requests, same code path.
The length check is the part worth keeping regardless of rustfs. It turns silent truncation into a loud failure for any S3-compatible store, and it costs one comparison per get.
Testing
Unit tests for the
Content-Rangeparser and for the byte-range chunking.Verified end to end against rustfs: the scenario that reproducibly crashed environmentd, with the blob cache disabled so every scan fetches from the store, now completes about 20 GiB of cold reads with no failures, twice. Before the change it panicked within a minute.
Gotchas for the reviewer
Content-Rangedenominator is load-bearing. The completeness check assumes it is the length of the whole object, not of the part that was requested. That is what S3 specifies, and what minio, garage, seaweedfs and rustfs all return. A store that reported the part's length there would fail every multipart get instead of silently truncating. The assumption is now called out in a comment at the parse site.x-amz-mp-parts-countis part of the S3 GetObject contract and rustfs already defines the constant internally without sending it. Worth an upstream report, which I have not filed yet.This is split out of #38800, which has the blob store benchmarking work that led to it.
🤖 Generated with Claude Code