server: stream response items as segments so large fields are not copied - #271
Conversation
Each item of a streaming response is now encoded through `Encodable::encode_segments`, and the framing stream emits every segment at or above the framing threshold as its own body frame by reference count. A handler streaming `OwnedView` items (or any body whose `encode_segments` yields segments) no longer pays a payload-sized memcpy per item; the tag/length fragments between large fields ride in the batch buffer with the envelope header. Wire bytes are unchanged. `StreamingResult` carries `EncodedStream` (a stream of `EncodedBody`) instead of a stream of `Bytes`, mirroring `EncodedResponse`. Compressed streams and interceptor chains flatten each item as before. Signed-off-by: rkk <rkk@anthropic.com>
…migration EncodedBody becomes the Dispatcher streaming contract with this change and has not shipped yet, so seal its variants now rather than after a release; callers already go through segments()/into_contiguous(). Put the Bytes -> EncodedBody conversion for hand-written dispatchers on EncodedStream's rustdoc as a compiled example rather than only in the changelog, and say on Response::compress and in the guide's view-body section that a compressed response flattens the segmented encode (the default for >1 KiB responses to a gzip-advertising client), so a large-field stream wants compress(false). Reword the fragment: message bytes are unchanged but HTTP frame boundaries move, and the setter is with_min_size. Includes the with_min_size rename in one test from rebasing over connectrpc#261. Signed-off-by: Iain McGinniss <309153+iainmcgin@users.noreply.github.com>
ace2777 to
8eebaad
Compare
|
[claude code] Reviewed for the 0.9.0 cut. Two passes (correctness and downstream-API) found no correctness issues: every ordering path through To make tomorrow's cut I've rebased onto current
Two things the review flagged as measurements owed after the cut rather than blockers: (1) on the compressed path each item is now rope-encoded, On your |
Summary
Extends #232's segmented encode to streaming responses. Today every stream item is encoded to one contiguous
Bytesbefore framing, soEncodable::encode_segmentsis never consulted on the streaming path and a large field the encoder could hand over by reference count is memcpy'd once per item. #219 already removed the second (framing) copy; this removes the first for any body that yields segments.With this change each item of a server-streaming or bidi response (and a client-streaming or unary response served over gRPC, which rides the same framing) goes through
encode_segments.BatchingEnvelopeStreamwrites the 5-byte envelope header into its batch buffer as before, then walks the item's segments in wire order: a segment at or aboveMIN_CHAIN_SIZEbecomes its own body frame, unmoved; a smaller one (the tag/length fragment between two large fields, or a short tail) is copied into the batch buffer and rides with whatever is batched next. Wire bytes are unchanged; only HTTP frame boundaries move, as in #219.Measured on a dev machine, one
OwnedViewitem with a single dominant field through the Connect framing stream (encode + frame, release build):Above the threshold the per-item cost is flat, since only the framing is still being written.
Where this deliberately does nothing
encode_segmentsis not overridden), so a handler streaming plain owned messages sees no change. See the question below.Payload, matching the unary path.Full<Bytes>; not touched here.Design notes
EncodedStream(Pin<Box<dyn Stream<Item = Result<EncodedBody, ConnectError>> + Send>>) is the streaming counterpart ofEncodedResponse's body and is whatStreamingResultnow carries.EnvelopeEncoder::encode_chainedtakes anEncodedBodyand returns the segments to chain (empty when the body was copied in below the threshold). The header-then-segments policy is one private helper,write_envelope_chained, shared withEnvelope::encode_body_parts, so the unary and streaming paths cannot drift.DeadlineStreamis generic over the item type rather than namingEncodedBody, since it never inspects items.EncodedBodyinto the streaming machinery (into_contiguous()for unary-over-gRPC and client-streaming responses) now pass it through, so those responses keep their segments too.Breaking change, confined to custom dispatch
StreamingResult's body isEncodedStreamrather thanBoxStream<Result<Bytes, ConnectError>>, andStreamResponse::{from_encoded, into_encoded}follow. Hand-writtenDispatcherimpls and test doubles map items withBytes::into()/.map(|r| r.map(Into::into))and recover a buffer withEncodedBody::into_contiguous(). Generated dispatchers only name the alias andencode_response_stream, so no regeneration is needed; handler traits are unchanged.Question for review: owned messages with
bytes::Bytesfieldsbuffa's
bytes_type(BytesRepr::Bytes)gives an owned messagebytes::Bytesfields whoseProtoBytes::as_sharedlets aRopecapture them, but the blanketimpl Encodable<M> for Mcannot tell such a message from one withVec<u8>fields, so it stays contiguous. A downstream crate can opt in today with a smallEncodablewrapper that encodes throughbuffa::Rope. Would you prefer that as a provided wrapper here (e.g.Segmented<M>, alongsidePreEncoded/MaybeBorrowed), or a type-level signal from buffa codegen that the blanket impl can branch on? Happy to send either as a follow-up.Testing
encode_chainedkeeps segments of a large body by pointer and declares the total; copies a small segmented body; compresses a segmented body as one (gzip).BatchingEnvelopeStream: a segmented item interleaved with a small item yields header+lead / large A / fragment / large B / tail+next envelope, large frames by pointer, reassembly decodes to the contiguous envelopes; error after a segmented item preserves order.encode_response_streamforwardsencode_segments;StreamResponse::from_encodedflattens.tests/streaming: a server stream ofOwnedViewitems with 20 KiB / 7 B / 48 KiB / 16 KiB fields round-trips over HTTP/1.1 (compression disabled so the segmented path is the one exercised).cargo test --workspace --all-features,cargo test -p connectrpc --no-default-features, clippy-D warnings,cargo +nightly-2026-02-27 fmt --check,cargo docwith-Dwarnings: clean.