Skip to content

Stream publisher origin bodies end-to-end on Fastly#867

Open
prk-Jr wants to merge 3 commits into
mainfrom
streaming-pipeline-async-core
Open

Stream publisher origin bodies end-to-end on Fastly#867
prk-Jr wants to merge 3 commits into
mainfrom
streaming-pipeline-async-core

Conversation

@prk-Jr

@prk-Jr prk-Jr commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Publisher pages were fully buffered before the first byte left the edge: the platform client materialized the origin body (10 MiB cap → 5xx above it), the rewrite pipeline ran over an in-memory cursor, and the EdgeZero finalize buffered the assembled response while awaiting auction collection — so TTFB tracked full origin transfer + auction instead of origin first byte (issue True origin streaming + EdgeZero streaming port #849, P0 finding 1 of the 2026-07-05 performance review).
  • This PR makes the Fastly publisher path stream end-to-end: the origin fetch requests a streaming body (capability-gated per platform), the pipeline decodes/rewrites/re-encodes chunk-by-chunk with a cumulative raw-byte cap, and the finalize attaches a lazy Body::Stream so headers commit at origin first byte while the auction rides the transfer — only the </body> tail is held so bids still inject before body close.
  • Measured local A/B (release builds via fastly compute serve, same seeded config, live origin, 183 KB gzip page, live 3-slot server-side auction, 20 interleaved rounds): TTFB median 741 ms buffered → 161 ms streamed (−78%, 4.6×); guest wall time (738 vs 744 ms) and wasm heap (4.3 vs 4.2 MB) unchanged — the win is purely overlapping delivery with transfer, and >10 MiB pages now stream instead of erroring. Buffered adapters (Axum/Cloudflare/Spin) keep the bounded buffered finalizer; their platform clients don't produce streams yet.

Changes

File Change
crates/trusted-server-core/src/platform/http.rs Add supports_streaming_responses() to PlatformHttpClient (default false) so callers only request the streaming contract where the adapter honors it
crates/trusted-server-adapter-fastly/src/platform.rs Fastly client reports supports_streaming_responses() = true
crates/trusted-server-core/src/publisher.rs Publisher origin fetch sets with_stream_response() when supported; BodyChunkSource (async chunk pull + cumulative raw-byte cap); publisher_response_into_streaming_response builds the lazy processed Body::Stream (auction hold inside the generator); shared hold_step_decoded_chunk/hold_finish_segments used by both async hold paths; collect_non_html_auction dedupes collect-before-stream; bodiless (HEAD/204/304) guard + wasted-dispatch warning; body_as_reader now errors on stream bodies instead of silently emptying them
crates/trusted-server-core/src/streaming_processor.rs Push-style BodyStreamDecoder/BodyStreamEncoder (write-based flate2/brotli codecs; brotli finalize uses close() so truncated input errors instead of silently truncating); shared STREAM_CHUNK_SIZE
crates/trusted-server-adapter-fastly/src/app.rs Publisher fallback dispatch returns the lazy streaming response instead of buffer_publisher_response_async
crates/trusted-server-adapter-fastly/src/main.rs send_edgezero_response doc: streaming now covers publisher bodies, not just assets
crates/trusted-server-core/src/proxy.rs stream_asset_body docs generalized — it now bridges publisher streams too
crates/trusted-server-core/src/platform/test_support.rs Stub client can report streaming support for gating tests
crates/trusted-server-core/src/settings.rs max_buffered_body_bytes doc: also the cumulative raw-byte cap on the streaming path; cap trip after headers truncates rather than 5xx
Cargo.toml, crates/trusted-server-core/Cargo.toml, Cargo.lock Add async-stream (already in the tree via edgezero-core) for the lazy body generator
docs/superpowers/plans/2026-07-08-true-origin-streaming-fastly.md Implementation plan for this change

Closes

Closes #849

Test plan

  • cargo test-fastly && cargo test-axum (core: 1642 passed; plus cargo test-cloudflare && cargo test-spin)
  • cargo clippy-fastly && cargo clippy-axum (plus clippy-cloudflare, clippy-cloudflare-wasm, clippy-spin-native, clippy-spin-wasm, all on 1.95.0)
  • cargo fmt --all -- --check
  • JS tests: cd crates/trusted-server-js/lib && npx vitest run
  • JS format: cd crates/trusted-server-js/lib && npm run format
  • Docs format: cd docs && npm run format
  • WASM build: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
  • Manual testing via fastly compute serve: side-by-side A/B vs main (20 interleaved rounds, live origin + live 3-slot auction) — TTFB 741 → 161 ms median, transfer-encoding: chunked with gzip preserved, bids injected before </body> on both builds, byte-parity on body size
  • Other: new regression tests — streamed gzip tail after </body> survives the auction hold; truncated brotli stream errors; cumulative cap enforcement; Stream-vs-Once parity across gzip/deflate/brotli/identity; stream-flag gating on/off

Checklist

  • Changes follow CLAUDE.md conventions
  • No unwrap() in production code — use expect("should ...")
  • Uses tracing macros (not println!)
  • New code has tests
  • No secrets or credentials committed

Publisher pages were fully buffered before the first byte reached the
client: the platform client materialized the origin body (10 MiB cap),
the rewrite pipeline ran over an in-memory cursor, and the EdgeZero
finalize buffered the assembled response while awaiting auction
collection. TTFB therefore tracked full origin transfer plus the
auction instead of origin first byte.

- Add supports_streaming_responses() to PlatformHttpClient (default
  false, Fastly true) and request with_stream_response() on the
  publisher origin fetch only where honored
- Teach the pipeline to consume Body::Stream asynchronously:
  BodyChunkSource (cumulative raw-byte cap via
  publisher.max_buffered_body_bytes), push-style
  BodyStreamDecoder/BodyStreamEncoder in streaming_processor
- Replace the Fastly buffered finalize with
  publisher_response_into_streaming_response: a lazy Body::Stream that
  commits headers at origin first byte, streams rewritten chunks, and
  holds only the </body> tail for auction collection; bids still
  inject before body close
- Share one hold implementation (hold_step_decoded_chunk /
  hold_finish_segments) between the lazy body and the writer-driven
  loop so the paths cannot drift; collect_non_html_auction dedupes the
  collect-before-stream path
- Finalize brotli decode with close() so truncated origin streams
  error instead of silently truncating; decode failures emit
  stream_decode_error telemetry
- Guard bodiless (HEAD/204/304) responses and log wasted auction
  dispatch, matching the buffered finalizer

Local A/B on a 183 KB gzip publisher page with a live 3-slot auction
(release builds, 20 interleaved rounds): TTFB median 741 ms buffered
vs 161 ms streamed (-78%); guest wall time and wasm heap unchanged.
@prk-Jr prk-Jr self-assigned this Jul 8, 2026
prk-Jr added 2 commits July 8, 2026 22:39
Address the deep-review findings on the streaming cutover:

- Cap cumulative decoded bytes in BodyStreamDecoder against
  publisher.max_buffered_body_bytes: the chunk source only bounds raw
  compressed bytes, so a decompression bomb could expand ~1000x past it
  and push unbounded decoded volume through the rewrite pipeline
- Detect truncated deflate streams: write::ZlibDecoder::try_finish
  accepts truncated input silently, so the deflate arm now drives
  flate2::Decompress directly and requires Status::StreamEnd at
  finalization; trailing bytes after the end marker stay ignored.
  Add truncated-gzip and truncated-deflate regression tests
- Make BodyChunkSource::next_chunk cancellation-safe by polling the
  body in place instead of moving it out across an await; a cancelled
  pull no longer turns into a silent EOF
- Log dispatched auctions dropped uncollected (client disconnect
  mid-stream or never-polled body) via DispatchedAuctionGuard; the
  guard is created before the lazy stream so unpolled drops log too
- Share the pull+decode step between the lazy publisher body and the
  write-sink drivers (hold_step_next_chunk / passthrough_step),
  removing the unreachable!() error plumbing and the triplicated
  processor selection; document body_close_hold_loop_stream as
  groundwork for the buffered adapters' streaming cutover
- Pass identity-encoded chunks through zero-copy and finish encoders
  by consuming them instead of allocating a throwaway replacement
- Add a Fastly dispatch test asserting the publisher fallback returns
  Body::Stream without a stale Content-Length, plus a comment on why
  the publisher fetch gates streaming on capability while the asset
  path does not

Behavior note: gzip bodies with trailing garbage after the trailer now
error mid-stream; the old read-path decoder ignored them.
The buffered finalizer abandons a dispatched auction with
processor_init_error telemetry when HTML processor construction fails;
the streaming finalizer dropped the in-flight SSP responses silently.
Make publisher_response_into_streaming_response async and emit the same
abandonment before returning the construction error.

@ChristianPavilonis ChristianPavilonis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Automated review:

Review Summary

Reviewed PR #867 against main, focusing on the new Fastly end-to-end publisher streaming path, body/HTTP semantics, decoder limits, adapter contracts, and regression coverage. The change is well-tested and CI is green, but I found two correctness/resource-safety issues that should be addressed before relying on this path in production.

Findings

See the inline comments for the detailed findings.

CI / Existing Reviews

gh pr checks 867 reports all checks passing, including Rust/JS tests, formatting, CodeQL, browser integration, and cross-adapter parity. No existing PR reviews or inline comments were present when this automated review ran.

services: RuntimeServices,
) -> Result<Response<EdgeBody>, Report<TrustedServerError>> {
match publisher_response {
PublisherResponse::Buffered(response) => Ok(response),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Automated review: P1 — Buffered streaming responses can leak bodies on bodiless responses

Fastly now requests with_stream_response() before the origin response is classified. Any response routed to BufferedUnmodified can therefore still carry an EdgeBody::Stream, and this arm returns it unchanged. The Fastly entry point later streams any EdgeBody::Stream, so HEAD, 204, 205, and unprocessable 304 responses can send body bytes even though HTTP requires them to be bodiless/preserve metadata only.

Please normalize the Buffered arm for the streaming finalizer (and ideally the common buffered finalizer) by clearing the body when !response_carries_body(method, response.status()), while preserving headers such as Content-Length; also include StatusCode::RESET_CONTENT in response_carries_body. Add regression tests with streaming bodies for HEAD, 204, 205, and 304.

.change_context(TrustedServerError::Proxy {
message: "Failed to decode gzip publisher body chunk".to_string(),
})?;
bytes::Bytes::from(std::mem::take(decoder.get_mut()))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Automated review: P1 — Decoded body cap is checked only after a compressed chunk fully expands

The streaming decoder writes a whole compressed chunk into the codec, drains the codec's internal Vec, and only then calls track_decoded(). A small compressed chunk can expand far beyond publisher.max_buffered_body_bytes before the cap is checked, so the configured limit is not a hard Wasm-heap ceiling. The current tests demonstrate this shape already: a sub-1 KiB gzip body expands to 64 KiB and is rejected only after that allocation has happened.

Please enforce the remaining decoded budget during decompression instead of after it, e.g. by decoding into a bounded sink/buffer that errors as soon as remaining + 1 bytes would be emitted, or by driving each codec with explicit output buffers sized to the remaining limit.

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.

True origin streaming + EdgeZero streaming port

2 participants