Repository navigation
Conversation
`S3Blob::get` reads a multipart-uploaded object part by part, using the `x-amz-mp-parts-count` header from 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`. rustfs is such a store, and every cold read of a blob above the 8 MiB multipart threshold, which is every large compaction output, crashed environmentd. The blob cache masked it for freshly written blobs. `get` now also reads the object's total size from `Content-Range`, which every store returns on a part request, fetches the remainder by byte range when the part count is missing, and checks the reassembled length against the total. Any store that returns a short object now fails the read, which persist retries, instead of corrupting data downstream. The existing path, where the header is present, is unchanged. Adds two ways to measure a blob store: * `persistcli bench blob` drives one store through persist's own blob client at a given object size and concurrency, writing, listing, reading and deleting, and reports throughput, latency percentiles and the retries persist's retry loop needed. Reads verify object length. * `test/blob-store-benchmark` sweeps that over object sizes, concurrency levels and stored volumes for several stores, samples each store container's CPU and memory, and writes a CSV. Also teaches the existing compositions about more blob stores. garage and rustfs join minio and azurite as `external_blob_store` targets for `Materialized` and `Testdrive`, resolved through a new `blob_store` module. The parallel benchmark takes `--blob-store` and `--other-blob-store` in place of `--azurite`, so two stores can be compared directly, and prints persist's blob operation counts and latencies after each scenario. Its new `BlobStoreReadsWrites` scenario disables the blob cache so that reads reach the store. garage ships without a shell, so it gets an mzbuild image that adds one for the cluster setup a fresh node needs. Tests: unit tests for the `Content-Range` parser and the byte-range chunking in `src/persist/src/s3.rs`. 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]>
seaweedfs joins minio, azurite, garage and rustfs as an `external_blob_store` target, so `--blob-store seaweedfs` works in both the parallel benchmark and the direct blob store benchmark. It runs the all-in-one `mini` mode, which is a master, volume server, filer and S3 gateway in one process. Three choices are not obvious: * An S3 key is configured. Without one seaweedfs serves every request unauthenticated and skips the signature verification the other stores perform, which would flatter it in a comparison. * The volume size limit is raised from the 128MiB `mini` default to the 1GiB its own `server` mode uses, so a benchmark's data does not spread over dozens of volumes, and the volume count is auto-sized from free disk so a long run cannot exhaust it. * The healthcheck goes through the filer rather than the S3 API, because the filer serves a bucket as a directory without credentials. So "healthy" means the buckets exist, not merely that the port is open. seaweedfs does not fsync on the write path by default, so it needs nothing extra to match the minio image's patch for durability parity. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Folds seaweedfs into the write-up and rebases every table on the single round that measured all four stores back to back, which is the only comparison the harness's run-to-run variance supports. The report and its generator are committed here rather than left loose because the previous copy, and the CSVs behind it, were lost to a working tree cleanup. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
jubrad
added a commit
that referenced
this pull request
Sep 28, 2026
#39115) ## Issue `S3Blob::get` reads a multipart-uploaded object part by part. It learns how many parts to fetch from the `x-amz-mp-parts-count` header 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 with `Invalid Parquet file. Corrupt footer`. That is not hypothetical. [rustfs](https://github.com/rustfs/rustfs) answers `GetObject?partNumber=1` with 206 and a correct `Content-Range` but no parts count, and its `HeadObject` ignores `partNumber` entirely. 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 `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 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-Range` parser 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 - **The `Content-Range` denominator 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. - **The check runs for every store**, not only the ones needing the byte-range fallback. That is deliberate: it is what converts a short read into a retryable error rather than a corrupt blob. It is also the only behavioural change visible to stores that already send the header. - The fallback only engages when the part count is absent *and* the first part is shorter than the total, so a genuine single-part object still takes exactly one request. - Byte-range chunks are sized from the first part rather than from persist's own multipart chunk size, so they line up with however the object was actually uploaded rather than with how we would upload it today. A store reporting a zero-length first part leaves no chunk size to reuse, so the remainder is fetched as a single range. - `x-amz-mp-parts-count` is 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](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
This branch has not been deployed
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.rustfs is such a store. Every cold read of a blob above the 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 send the header.
I found this while benchmarking blob stores, and could not tell the two stores apart on the existing instruments, which is the other half of this PR.
Solution
The read path.
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, nothing changes.The length check is the part worth keeping regardless of rustfs: it turns silent truncation into a loud failure for any S3-compatible store.
Measuring a blob store. Two instruments, because the existing ones cannot separate the store from Materialize's own work:
persistcli bench blobdrives one store through persist's own blob client at a given object size and concurrency. It writes, lists, reads at random and deletes, reporting throughput, latency percentiles, and the retries persist's retry loop needed, so a throttling store shows up as latency and a retry count instead of an error. Reads verify object length.test/blob-store-benchmarksweeps that across object sizes, concurrency levels and stored volumes for several stores, samples each store container's CPU and memory, and writes a CSV. Manual-only, so it is excluded from the CI reference lint.More blob stores in the compositions. garage, rustfs and seaweedfs join minio and azurite as
external_blob_storetargets forMaterializedandTestdrive, resolved through a newblob_storemodule. The parallel benchmark takes--blob-storeand--other-blob-storein place of--azurite(nothing in CI passed that flag to this composition), so two stores can be A/B'd directly, and it prints persist's blob operation counts and latencies after each scenario. The newBlobStoreReadsWritesscenario disables the blob cache so reads actually reach the store.garage ships an image with no shell, so it gets an mzbuild image that adds one for the cluster setup a fresh node needs before it serves S3.
What the benchmarks found
Measured on one laptop under Docker, one store at a time, no resource limits, fsync off everywhere. Treat these as relative and provisional.
A control run matters here: minio's configuration was unchanged between two rounds, yet its per-cell throughput moved by a median of -6% with a 10th-to-90th range of -44% to +43%. A single-run cell in this harness is not interpretable below roughly 50% difference. Only the large, consistent gaps below clear that bar.
Throughput against minio, across all five object sizes at an empty store, from one round with all four stores measured back to back. Above 1.0 is slower than minio.
test/garage/garage.toml.SlowDownfrom an internal write admission limit. ItsRUSTFS_HEAL_ENABLEDandRUSTFS_SCANNER_ENABLEDswitches appear inert in 1.0.0-rc.5, so it carries background work minio has switched off.For persist, which writes batch parts from a few hundred KiB up to a 128 MiB target, seaweedfs is fastest where persist writes least and slowest where it writes most.
blob-store-benchmark-report.mdhas the full matrices, scaling curves and caveats. Its numbers are specific to one laptop, so say the word if you would rather it not live in the repo and I will drop that commit.Testing
Unit tests for the
Content-Rangeparser and the byte-range chunking. The behavioral fix was verified end to end: the scenario that reproducibly crashed environmentd on rustfs (blob cache off, so every scan fetches from the store) now completes with about 20 GiB of cold reads and no failures, twice.Gotchas for the reviewer
--azuriteis gone from the parallel benchmark in favour of--blob-store azurite. No CI pipeline passed that flag to this composition, so nothing in CI changes.test/garagemzbuild image on this PR.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.🤖 Generated with Claude Code