Skip to content

fix: eliminate cross-request body I/O on multipart & batch-delete writes#100

Merged
alukach merged 2 commits into
mainfrom
fix/cross-request-body-io
Jun 30, 2026
Merged

fix: eliminate cross-request body I/O on multipart & batch-delete writes#100
alukach merged 2 commits into
mainfrom
fix/cross-request-body-io

Conversation

@alukach

@alukach alukach commented Jun 30, 2026

Copy link
Copy Markdown
Member

Problem

Multipart and batch-delete writes intermittently fail with 500/503 under a cold-isolate burst on Cloudflare Workers:

[ERROR] multistore::proxy: failed to read request body { error=arrayBuffer await failed:
  Error: Cannot perform I/O on behalf of a different request. ... (I/O type: ReadableStreamSource) }

Only the first few requests in a large batch fail; the rest succeed.

Root cause

For NeedsBody operations the inbound body is read via Response.array_buffer() in handle_request's NeedsBody arm — after resolve_request_with_metadata awaits the bucket lookup and the backend-auth middleware's STS AssumeRoleWithWebIdentity exchange. wasm-bindgen-futures drains a single microtask queue shared across all concurrent requests in an isolate, so a body read deferred past those awaits can resume while a different request is the active I/O context — which the runtime rejects. A cold OIDC credential cache parks the opening burst of writes on the same STS await, which is why only the opening requests fail.

The no_handle_cross_request_promise_resolution flag does not help — it only toggles cancel-with-warning (503) vs run-into-the-hard-error (500).

Fix

Get the bytes into owned memory in the request's own I/O context, or don't read the stream in WASM at all. Three parts:

1. Pre-read the small buffered ops in-context (crates/core/src/proxy.rs). The multipart-control + batch-delete ops (CreateMultipartUpload, CompleteMultipartUpload, AbortMultipartUpload, DeleteObjects) must parse their body, so they still buffer — but the read now happens at the top of handle_request, before any cross-request await, gated by a synchronous classifier (op_needs_buffered_body) that decides from the parsed operation with no I/O.

2. Stream plain UploadPart instead of buffering (crates/core/src/proxy.rs). A plain (non-aws-chunked) part previously buffered the whole part into WASM memory via array_buffer() (the largest, highest-concurrency body — and the one hitting the cross-request read). It now forwards zero-copy through build_streaming_forward with UNSIGNED-PAYLOAD header signing, mirroring PutObject's streaming write. content-md5 and x-amz-checksum-* are forwarded and signed so S3 still validates part integrity. build_streaming_forward is generalized with a forward_header_names param (aws-chunked path unchanged — same headers, byte-for-byte).

3. Wrap streamed PUT bodies in FixedLengthStream (crates/cf-workers/src/backend.rs). Required to make part 2 actually work: a bare ReadableStream attached as a subrequest body makes the Workers runtime send Transfer-Encoding: chunked and drop Content-Length. S3 rejects a non-aws-chunked PUT/UploadPart with no Content-Length (it can't size the payload), so the subrequest hangs until the whole body streams through, or 501s, and the client retries — observed as stalled PUTs. Wrapping non-aws-chunked PUT bodies that carry a Content-Length in a FixedLengthStream makes the runtime emit a real Content-Length; the outbound fetch drives the pipe via backpressure, so it still streams (no buffering). aws-chunked bodies are sized by S3 from x-amz-decoded-content-length and keep their raw chunk framing. Also fixes the latent same-shaped issue for plain PutObject. (Adds the TransformStream + WritableStream web-sys features.)

Net: no large body is ever materialized, every buffered read happens in its originating request's context, and streamed parts reach S3 well-formed.

Tests

  • upload_part_plain_streams_unsigned_preserving_checksum — plain part → Forward carrying partNumber/uploadId, x-amz-content-sha256: UNSIGNED-PAYLOAD, checksum preserved.
  • op_needs_buffered_body_matches_needsbody_ops — classifier matches exactly the NeedsBody ops, excludes streaming/read ops.
  • All existing core tests pass; aws-chunked UploadPart, oversized-part rejection, and CreateMultipartUpload → NeedsBody unchanged.

Verification note

The cross-request I/O behavior and the FixedLengthStream framing are Workers-runtime behaviors that unit tests can't exercise — they need a real deployment. Confirm on a preview that a parallel multipart upload and a large batch-delete no longer 500/503 or stall.

🤖 Generated with Claude Code

Multipart and batch-delete writes could fail with 500/503 under a cold-isolate
burst on Cloudflare Workers: "Cannot perform I/O on behalf of a different
request". The inbound body was read via Response.array_buffer() in the
NeedsBody arm, *after* resolve_request_with_metadata awaits the bucket lookup
and the STS AssumeRoleWithWebIdentity exchange. wasm-bindgen-futures shares one
microtask queue across concurrent requests in an isolate, so that deferred body
read can resume under another request's I/O context, where the runtime rejects
it. A cold OIDC credential cache synchronizes the first burst of writes onto the
STS await, which is why only the opening requests fail and the rest succeed.

- Plain (non-aws-chunked) UploadPart now streams via build_streaming_forward
  with UNSIGNED-PAYLOAD header signing instead of buffering. The part body is
  never materialized; content-md5 and x-amz-checksum-* are forwarded and signed
  so S3 still validates part integrity. Mirrors PutObject's streaming write.

- The remaining buffered ops (CreateMultipartUpload, CompleteMultipartUpload,
  AbortMultipartUpload, DeleteObjects) carry only a small body that must be
  parsed, so they still buffer -- but the body is now collected at the top of
  handle_request, in the request's own I/O context, before any cross-request
  await. A synchronous classifier (op_needs_buffered_body) decides this from the
  parsed operation with no I/O.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 30, 2026

Copy link
Copy Markdown

🚀 Latest commit deployed to https://multistore-proxy-pr-100.development-seed.workers.dev

  • Date: 2026-06-30T18:42:41Z
  • Commit: fb00a22

…ntent-Length

A bare ReadableStream attached as a subrequest body makes the Workers runtime
send Transfer-Encoding: chunked and drop Content-Length. S3 rejects a
non-aws-chunked PUT/UploadPart with no Content-Length (it can't size the
payload), so the subrequest hangs until the whole body streams through (or
501s) and the client SDK retries with backoff -- observed as many stalled PUTs
under a parallel multipart upload.

Wrap non-aws-chunked PUT bodies that carry a Content-Length in a
FixedLengthStream so the runtime emits a real Content-Length and a non-chunked
request. The outbound fetch drives the pipe via backpressure, so it still
streams (no buffering). aws-chunked bodies are sized by S3 from
x-amz-decoded-content-length and keep their raw chunk framing.

Also fixes the latent same-shaped issue for plain (non-aws-chunked) PutObject.

Adds the TransformStream + WritableStream web-sys features.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@alukach alukach changed the title fix(proxy): stream plain UploadPart, pre-read buffered bodies in-context fix: eliminate cross-request body I/O on multipart & batch-delete writes Jun 30, 2026
@alukach alukach merged commit 60b4278 into main Jun 30, 2026
13 checks passed
@alukach alukach deleted the fix/cross-request-body-io branch June 30, 2026 19:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant