Skip to content

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

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

Stream publisher origin bodies end-to-end on Fastly#867
prk-Jr wants to merge 15 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

Known limitation (deferred)

Configs with an HTML post-processor registered (the nextjs integration) do not stream yet: HtmlWithPostProcessing accumulates the full rewritten document and runs post-processors at origin EOF, so body bytes for those pages are not emitted until the complete origin transfer. Headers still commit early. Tracked in #924; also recorded in the plan doc's Out-of-scope section.

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.

Comment thread crates/trusted-server-core/src/publisher.rs Outdated
Comment thread crates/trusted-server-core/src/streaming_processor.rs Outdated

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

Review Summary

I found two high-impact issues and two medium-severity issues in the new Fastly streaming path. Valid deflate responses can be truncated, and enabling the supported Next.js integration still withholds every body byte until origin EOF, so issue #849's FCP objective is not met for that configuration. Valid multi-member gzip framing and cancellation-time auction observability also need correction.

I am requesting changes. The two earlier unresolved threads—bodiless streamed responses and decoded-limit enforcement only after a complete expansion—also remain current and are intentionally not duplicated here. All 19 CI checks are green, but the focused codec cases below are not covered by them.

Comment thread crates/trusted-server-core/src/streaming_processor.rs Outdated
Comment thread crates/trusted-server-core/src/publisher.rs
Comment thread crates/trusted-server-core/src/streaming_processor.rs Outdated
Comment thread crates/trusted-server-core/src/publisher.rs Outdated
Resolve five correctness and resource-safety findings from the PR #867
review of the end-to-end Fastly publisher streaming path.

- Drive the deflate decoder to StreamEnd at finalization so a valid
  stream that exactly fills the internal output buffer is no longer
  rejected as truncated; the inflater is also drained after all input is
  consumed within a chunk.
- Decode concatenated (multi-member) gzip bodies via MultiGzDecoder on
  both the streaming decoder and the buffered read pipeline so adapters
  agree.
- Enforce the decoded-body cap during decompression through a bounded
  sink shared by the gzip and brotli codecs, so a compression bomb errors
  before its expanded bytes are buffered instead of after a full chunk
  expands; the deflate codec charges each produced block as it is emitted.
- Drop the body of bodiless responses (HEAD, 204, 205, 304) in both the
  streaming and buffered finalizer Buffered arms, and add RESET_CONTENT
  to response_carries_body, so a buffered-unmodified stream body is never
  streamed to the client for a response that must be bodiless.
- Keep the dispatched-auction guard armed across the collection await and
  disarm it only once collection reaches a terminal result, so a body
  dropped while collection is pending still logs the discarded SSP work.

Add regression tests for the deflate output-buffer boundary, multi-member
gzip, bodiless buffered stream bodies, and the auction guard sentinel.

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

Review Summary

Reviewed PR #867 at c8ae53f against main. The lazy Fastly path is directionally sound, but two buffering points still undermine issue #849's FCP objective, and two HTTP/telemetry edge cases remain. All 19 CI checks currently pass; details are in the inline comments.

Comment thread crates/trusted-server-core/src/streaming_processor.rs
Comment thread crates/trusted-server-core/src/publisher.rs Outdated
Comment thread crates/trusted-server-core/src/publisher.rs
Comment thread crates/trusted-server-core/src/publisher.rs
prk-Jr added 6 commits July 16, 2026 16:22
Resolve conflicts in favor of the streaming pipeline:
- app.rs: keep the lazy publisher_response_into_streaming_response
  import; Fastly streams, so buffer_publisher_response_async is unused
  here.
- publisher.rs: keep module-level flate2/futures imports and the
  body.is_stream() streaming branch in stream_html_with_auction_hold.

Main moved core to edition 2024 via workspace inheritance, enabling
let-chains and tripping clippy::collapsible_if; collapse the nested if
in passthrough_finish_segments to satisfy it.
BodyStreamEncoder::encode_chunk wrote each processed chunk into the
gzip/deflate/brotli codec and drained the inner buffer, but never
flushed the codec. Compressors buffer internally: write_all alone left
gzip emitting at most its 10-byte header and deflate/brotli emitting
nothing until finish(). Fastly commits the response headers early, so
the browser saw an open stream with no decodable HTML until the entire
origin transfer completed — the FCP regression tracked in #849.

Sync-flush the flate2 encoders (Flush::Sync — byte-aligned, no trailer)
and emit a brotli flush marker (BROTLI_OPERATION_FLUSH) after each
write. finish() still writes the terminating trailer, so the full
stream stays valid. Add a regression test that decodes the first chunk
with a flushed-but-unfinished decoder for every codec.
The close-body hold step processed a chunk into ready segments and the
held closing tail together, but awaited collect_stream_auction() before
returning any of them. When </body> landed in the first source chunk
(common for small pages), the client received no HTML until the auction
finished; for larger pages the whole prefix from the close-containing
chunk was delayed — contradicting the contract that only the </body>
tail is held while the auction rides alongside transfer.

Split hold_step_decoded_chunk so it returns the ready prefix plus a
close_found flag without collecting, and move collection plus held-tail
processing into a new hold_collect_close_tail. Both the lazy Fastly
stream and the write-sink driver now emit the ready prefix first, then
await collection, then emit the tail. Add a regression test asserting
the prefix is ready with close_found set while the auction is still
uncollected (ad_bids_state stays None until the tail step).
Both publisher finalizers dropped the body for bodiless responses but
kept the origin Content-Length verbatim, including a possible nonzero
value. RFC 9110 §8.6 forbids Content-Length on 204, and a nonzero length
on a now-empty 205 (§15.4.6) is invalid; inconsistent framing risks
interoperability failures and response-splitting across intermediaries.

Add make_response_bodiless: it empties the body and removes
Content-Length for 204, normalizes it to 0 for 205, and preserves it for
HEAD and 304 (which legitimately advertise the GET representation
length). Call it from the buffered-unmodified arm of both
buffer_publisher_response_async and publisher_response_into_streaming_response.

Split the combined bodiless regression test into per-status expectations
and add the matching coverage for the buffered finalizer.
A conditional navigation can dispatch a server-side auction and receive
a processable HTML 304 that classification routes to Stream. Both
finalizers' bodiless branches logged a warning and returned, dropping
params.dispatched_auction without emit_abandoned_auction — so the SSP
work and quota consumption had no terminal auction event, leaving a gap
in auction observability on a legitimate conditional-request path.

Take the dispatched auction in the bodiless branch of both
buffer_publisher_response_async and publisher_response_into_streaming_response
and await emit_abandoned_auction(..., "bodiless_response") with the
retained observation, matching the processor-init-error path. Add a
recording-telemetry-sink test (via a new test_support helper) asserting
both finalizers emit the bodiless_response abandonment for a 304 with a
dispatched auction.
The merge moved core to edition 2024, whose rustfmt sorts use-imports
case-sensitively (lowercase items last) and rewraps long lines. Reformat
the conflict-resolved imports and the new test assertions to match; no
behavior change.

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

Summary

Reviewed PR #867 at 5d6fc21f6 against main. All four findings from the 2026-07-14 review are fixed with one-to-one regression tests (per-chunk encoder flush, ready-prefix-before-collect, 204/205 framing, bodiless abandonment telemetry), and the earlier deflate/multi-gzip/decode-cap/guard findings all hold at head. One P1 from the 2026-07-13 review remains unaddressed and unanswered — post-processor configs still buffer the full document — plus one behavioral divergence and two smaller cleanups. All 19 CI checks pass.

Blocking

❓ question

  • Next.js-enabled configs still deliver zero body bytes until origin EOF: HtmlWithPostProcessing accumulates the whole rewritten document whenever a post-processor is registered, so the lazy stream emits nothing until EOF + post-process + recompress; #849's FCP objective is unmet for that configuration and the exception isn't documented (crates/trusted-server-core/src/publisher.rs:369 — see inline comment)

Non-blocking

🤔 thinking

  • gzip trailing-garbage tolerance regressed vs main, and gzip/deflate now diverge: MultiGzDecoder errors on trailing junk that single-member GzDecoder ignored, while the deflate arm deliberately ignores trailing bytes (crates/trusted-server-core/src/streaming_processor.rs:150 — see inline comment)

⛏ nitpick

  • Post-release chunks copied needlessly in hold_step_decoded_chunk (crates/trusted-server-core/src/publisher.rs:731 — see inline comment)

🏕 camp site

  • publisher.rs is now ~7,300 lines: the streaming machinery added here (BodyChunkSource, AuctionHoldState, DispatchedAuctionGuard, the hold_*/passthrough_* helpers, both finalizers) is a coherent unit that would read better as a publisher/streaming.rs submodule — not necessarily in this PR, but the file is past the point where navigation hurts

CI Status

  • fmt: PASS
  • clippy: PASS
  • rust tests (fastly/axum/cloudflare/spin + parity): PASS
  • js tests / format / docs format: PASS
  • browser + integration tests: PASS

Comment thread crates/trusted-server-core/src/publisher.rs
Comment thread crates/trusted-server-core/src/streaming_processor.rs Outdated
Comment thread crates/trusted-server-core/src/publisher.rs Outdated
prk-Jr added 3 commits July 17, 2026 13:07
Replace flate2's MultiGzDecoder with a shared GzipStreamDecoder used by
both the streaming BodyStreamDecoder and the buffered pipeline (via a
Read adapter). At each member boundary the next bytes are sniffed for
the gzip magic number: a match starts the next member, anything else is
dropped once at least one member has fully decoded — matching GNU gzip,
main's single-member tolerance, and the deflate codec. Truncated or
corrupt members still error.
Once the close-body hold is released, decoded chunks were still copied
via to_vec() before processing. Use Cow so the post-release path borrows
the chunk and only the held path allocates.
HTML post-processor configs (the nextjs integration) accumulate the full
rewritten document until origin EOF, so those pages do not stream body
bytes early. Record the limitation and the intended follow-up (an
up-front or streaming should_process gate) in the plan's Out of scope
section.
@prk-Jr

prk-Jr commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

Re the camp-site note on publisher.rs size: agreed on the shape — the streaming machinery (BodyChunkSource, AuctionHoldState, DispatchedAuctionGuard, the hold_*/passthrough_* helpers, both finalizers) is a coherent unit. Deferring the publisher/streaming.rs extraction so this PR stays reviewable; happy to do it as an immediate follow-up.

Summary of this round (33384cd, 911268f, 666b3cb):

@prk-Jr
prk-Jr requested a review from aram356 July 17, 2026 07:42
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

3 participants