persist: fix multipart blob reads and add blob store benchmarks - #38800
Draft
jubrad wants to merge 1 commit into
Draft
persist: fix multipart blob reads and add blob store benchmarks#38800jubrad wants to merge 1 commit into
jubrad wants to merge 1 commit into
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) <noreply@anthropic.com>
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 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 and rustfs 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.
test/garage/garage.tomlwith the reasoning.SlowDownfrom an internal write admission limit. Persist rides that out with retries, but those cells lose most of their throughput.RUSTFS_HEAL_ENABLED=falseandRUSTFS_SCANNER_ENABLED=falseappear inert in 1.0.0-rc.5: the heal manager and scanner log identical startup and cycle messages with and without them. So rustfs carries background work that minio has switched off, with no evident way to disable it.I have a fuller write-up with the full matrices, scaling curves and caveats that I did not commit, since the numbers are machine-specific. Happy to add it to the PR or paste it in a comment.
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