Skip to content

persist: fix multipart blob reads and add blob store benchmarks - #38800

Draft
jubrad wants to merge 3 commits into
mainfrom
jubrad/blob-store-benchmark
Draft

jubrad wants to merge 3 commits into
mainfrom
jubrad/blob-store-benchmark

Conversation

@jubrad

@jubrad jubrad commented Sep 11, 2026 •

Copy link
Copy Markdown
Member

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.

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. 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, 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 blob drives 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-benchmark sweeps 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_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 (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 new BlobStoreReadsWrites scenario 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.

operation rustfs garage seaweedfs
set 0.6x to 1.7x 1.0x to 3.4x 0.7x to 9.6x
get 0.9x to 1.5x 4.3x to 5.6x 0.8x to 5.2x
delete 1.9x to 4.1x 0.7x to 2.3x 0.4x to 1.0x
store peak RSS peak CPU (cores) retries persist needed
minio 5.6 GiB 13.1 0
rustfs 2.3 GiB 13.6 56
garage 706 MiB 9.0 0
seaweedfs 25.8 GiB 18.1 62
  • garage is roughly 4x behind minio on reads at every size and saturates by 32 operations in flight, but does it on a fraction of the memory. Its defaults were badly wrong for persist's object sizes: with 10 MiB blocks and a raised read-concurrency cap, its 8 MiB reads went from 70 to 470 ops/s. The reasoning lives in test/garage/garage.toml.
  • rustfs tracks minio on reads and writes, is 3 to 4x behind on deletes, and refuses concurrent 64 MiB uploads with SlowDown from an internal write admission limit. Its RUSTFS_HEAL_ENABLED and RUSTFS_SCANNER_ENABLED switches appear inert in 1.0.0-rc.5, so it carries background work minio has switched off.
  • seaweedfs is the fastest of the four on small objects and on deletes at every size, and the worst on large writes. At 64 MiB with 128 operations in flight it stopped making progress: 0.0 ops/s and a p50 of about 93 minutes per write with 62 retries, against minio's 28 ops/s, while its resident memory climbed to 25.8 GiB. Reads in that same cell still ran, so writes specifically fell over. That is a harsher failure than rustfs's, which at least refuses work promptly.

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.md has 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-Range parser 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

  • This is really two changes, the persist fix and the benchmark tooling, and I am happy to split them if you would rather review the production change on its own. The persist change is about 130 lines of the diff.
  • --azurite is 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.
  • CI will build the new test/garage mzbuild image on this PR.
  • seaweedfs is given a static S3 key deliberately. With no S3 config it serves every request unauthenticated and skips the signature verification the other stores perform, which would flatter it in a comparison.
  • 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.
  • Lint and formatting were not run against the final state of this branch.

🤖 Generated with Claude Code

jubrad and others added 3 commits September 11, 2026 15:38
`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

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant