Skip to content

persist: read multipart blobs from stores without x-amz-mp-parts-count - #39115

Merged
jubrad merged 1 commit into
mainfrom
jubrad/persist-multipart-parts-count
Sep 28, 2026
Merged

jubrad merged 1 commit into
mainfrom
jubrad/persist-multipart-parts-count

Conversation

@jubrad

@jubrad jubrad commented Sep 24, 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.

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. 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

@jubrad
jubrad force-pushed the jubrad/persist-multipart-parts-count branch from fb945a5 to 66bfa62 Compare September 24, 2026 21:23
`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
jubrad force-pushed the jubrad/persist-multipart-parts-count branch from 66bfa62 to 91eb807 Compare September 24, 2026 21:29
@jubrad
jubrad requested a review from tonydu-mz September 24, 2026 22:17
@jubrad
jubrad marked this pull request as ready for review September 25, 2026 13:26
@jubrad
jubrad requested a review from a team as a code owner September 25, 2026 13:26
@jubrad
jubrad requested a review from ublubu 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 ublubu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.

Happy to wait until the benchmarking stuff goes in to have an automated way to test this on rustfs.

@jubrad

jubrad commented Sep 28, 2026

Copy link
Copy Markdown
Member Author

@ublubu I did test through some rough draft benchmark code here #38800 and here MaterializeInc/materialize-terraform-self-managed#485

@jubrad
jubrad merged commit adf2714 into main Sep 28, 2026
102 checks passed
@jubrad
jubrad deleted the jubrad/persist-multipart-parts-count branch September 28, 2026 17:15
@bosconi bosconi mentioned this pull request Oct 2, 2026
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.

2 participants