client: take an async Stream for client-streaming requests#227
client: take an async Stream for client-streaming requests#227iainmcgin wants to merge 4 commits into
Conversation
Client-streaming calls previously took a synchronous iterator, which cannot express uploads whose messages become available over time (paced producers, socket readers, channel consumers) without buffering the whole request or blocking a thread. call_client_stream and the generated client methods now take impl Stream<Item = Req> + Send + 'static. The stream backs the HTTP request body directly: the new EncodingBody envelope-encodes each message in poll_frame, replacing the previous bounded-channel-plus-detached-send-task pump. This hands upload liveness to the HTTP layer: backpressure is flow control, a server that ends the RPC mid-upload surfaces immediately even while the caller's stream is idle, and a server that sends response headers early while continuing to read the upload keeps receiving messages. An encode failure aborts the request and is surfaced as the call's error even when a success response races the abort. The Stream trait and a stream_iter adapter (futures::stream::iter) are re-exported from connectrpc::client, so migrating a ready collection is client.sum(connectrpc::client::stream_iter(requests)) with no new caller dependency. Adds sync_wrapper so the boxed body is Sync without requiring Sync of the caller's stream. Signed-off-by: Iain McGinniss <309153+iainmcgin@users.noreply.github.com>
Replace the raw impl Stream<Item = Req> + Send + 'static bound on client-streaming calls with impl ClientRequestStream<Req> - a sealed trait implemented automatically for every qualifying stream. Its #[diagnostic::on_unimplemented] attribute turns the common misuse (passing a collection directly) into actionable compiler output that names the stream_iter adapter and explains the Send + 'static requirement, and a borrowing stream's lifetime error now points at a documented, named bound. Named ClientRequestStream to avoid colliding with the server-side dispatcher::RequestStream byte-stream alias. Signed-off-by: Iain McGinniss <309153+iainmcgin@users.noreply.github.com>
client_stream_many_small (1000 small messages) isolates the per-message path from call setup; client_stream_large (10 x 1 MiB) makes the per-message encode/copy cost significant relative to the wire. Both were used to compare the stream-backed request body against the previous channel design on dedicated hardware. iter_batched keeps the input clone out of the timed section. No-Verification-Needed: bench-only change, no runtime surface Signed-off-by: Iain McGinniss <309153+iainmcgin@users.noreply.github.com>
|
[claude code] Benchmarked the stream-as-body design against the previous channel-based client-streaming (base
Reading:
The two new bench groups ( |
#226 fixed the channel-era client-stream leak by racing the detached send task against receiver closure. The stream-as-body design has no detached task — dropping the call future drops the transport send and the request body directly — so the mechanism is superseded while the guarantees are kept: - the # Cancellation rustdoc sections are ported (client-stream text adapted to the direct-drop mechanism; server-stream text unchanged) - all five #226 tests are ported to the stream API; the mid-drain backpressure case becomes an unread-request-body case, since there is no drain loop or channel to park in - tokio/macros stays on the base dependency per #226's consolidation, with the comment narrowed to the server accept loop now that the client-stream path no longer uses select! - the #226 changelog fragment is reworded to describe the behavior (abandonment stops the in-flight send) rather than the superseded background-task mechanism Signed-off-by: Iain McGinniss <309153+iainmcgin@users.noreply.github.com>
|
[claude code] Rebased onto main via merge: #226's abandonment fix targeted the channel-era detached send task, which this PR deletes — dropping the call future now drops the transport send and request body directly, so the guarantee holds structurally. All five #226 tests are ported to the stream API (the mid-drain backpressure case becomes an unread-request-body case, since there is no drain loop to park in), the |
| // the precise encode error instead of the generic transport failure — | ||
| // unconditionally, because a server's early response can race the abort | ||
| // and produce an `Ok` result for a truncated, encode-aborted upload. | ||
| if let Some(err) = encode_error |
There was a problem hiding this comment.
This covers the early-Ok race, but there's a second direction: with a background-driven body, an encode error can land after this check and is then never read — the call returns Ok for a truncated tail. I think that's fine (the server already finished its response), but a sentence saying it's intentional would help a future reader.
| #[doc = " are sent as the stream yields them. It backs the request"] | ||
| #[doc = " body, so yield owned messages or feed the call from a"] | ||
| #[doc = " channel-backed stream. For a collection that is already in"] | ||
| #[doc = " hand, wrap it with `::connectrpc::client::stream_iter(...)`."] |
There was a problem hiding this comment.
Could we add a cancellation note here? This is the doc users actually read, and dropping the returned future (e.g. a timeout firing) now silently drops unsent messages. Something like: "Dropping the returned future cancels the call; unsent messages are never delivered." A matching sentence in the guide would be nice too.
| /// so the compiler can point at the two usual fixes when the bound is not | ||
| /// met: wrap a ready collection with [`stream_iter`], and make a borrowing | ||
| /// stream yield owned messages (the stream backs the request body, which can | ||
| /// outlive the call frame and move across threads — hence `Send + 'static`). |
There was a problem hiding this comment.
Worth a sentence here on panics: the stream is polled on the transport's task now, so a panic in poll_next surfaces as a generic transport error instead of propagating (and can take down a shared connection). Would save someone a confusing debug session.
| Req: buffa::Message + crate::codec::JsonSerialize, | ||
| { | ||
| type Data = Bytes; | ||
| type Error = ConnectError; |
There was a problem hiding this comment.
Might as well add:
fn is_end_stream(&self) -> bool {
self.done
}The default is always false, so downstream layers need an extra poll_frame to notice exhaustion — done already means exactly this.
| // can surface the precise error instead of a generic transport failure. | ||
| let encode_error: std::sync::Arc<std::sync::Mutex<Option<ConnectError>>> = | ||
| std::sync::Arc::default(); | ||
| let body: ClientBody = EncodingBody { |
There was a problem hiding this comment.
Nothing forces a construction site to also drain the mirror — the pairing is convention only. Maybe EncodingBody::new(...) -> (Self, EncodeErrorSlot) with a #[must_use] slot? Small change, makes the contract compiler-visible.
| service: &str, | ||
| method: &str, | ||
| requests: impl IntoIterator<Item = Req>, | ||
| requests: impl ClientRequestStream<Req>, |
There was a problem hiding this comment.
Style nit, take it or leave it: the other call_* fns name all their generics; S: ClientRequestStream<Req> would match them and show the bound in rustdoc. (Not about turbofish — that already works; naming it would just add a _ to the conformance call.)
| /// Re-export of [`futures::stream::iter`]: adapts a collection that is | ||
| /// already in hand into a request stream for a client-streaming call, | ||
| /// without a direct `futures` dependency. | ||
| pub use futures::stream::iter as stream_iter; |
There was a problem hiding this comment.
Small one: we re-export UnaryResponse/ServerStream/BidiStream at the crate root, but not stream_iter, which most callers will want. pub use client::stream_iter; in lib.rs would shorten the common call site.
Summary
Client-streaming calls now take an async
Streamof requests instead of a synchronous iterator.call_client_streamand the generated client-streaming methods acceptimpl ClientRequestStream<Req>— a sealed trait implemented automatically for everyStream<Item = Req> + Send + 'static— so request messages can be produced as they become available — paced by timers, read from sockets, forwarded from channels — without buffering the whole upload or blocking a thread. The synchronous-iterator shape could not express this: there was no way to await between messages, which ruled out pump-style uploads (e.g. forwarding chunks under a per-chunk idle deadline).Breaking change, targeted at 0.9.0. Migration for a ready collection is one line, with no new dependency:
Stream(thefutures0.3 trait) andstream_iter(futures::stream::iter) are re-exported fromconnectrpc::client, and every generated client-streaming method carries the migration hint in its rustdoc. The bound trait carries#[diagnostic::on_unimplemented], so passing a collection directly produces compiler output that namesstream_iterand explains theSend + 'staticrequirement, rather than a bare unmet-trait error.Design: the stream is the request body
The stream is not pumped by a library-side drain loop — it backs the HTTP request body directly (
EncodingBody, which envelope-encodes each message inpoll_frame). The transport polls for the next frame only while it can send, so:An encode failure is emitted through the body (aborting the request) and mirrored into the call's return value so the caller sees the precise error. The stream is held in a
sync_wrapper::SyncWrapper(new tiny zero-dep dependency, the axum pattern) so the boxed body isSyncwithout requiringSyncof the caller's stream. The previous channel + detached-send-task machinery for client-streaming is deleted; bidi is unchanged (its push-stylesend()API keeps the channel).Changes
connectrpc/src/client/mod.rs: signature,EncodingBody,Stream/stream_iterre-exports, rewritten docs with# Errors.connectrpc-codegen/src/codegen.rs: generated arg type + doc hint; checked-in generated dirs regenerated.docs/guide.mdshows bothstream_iterand a channel-backed producer.Testing
task lint/task testgreen; clippy all-targets-D warningsclean; docs with broken-links denied; MSRV (1.88), wasm32, and minimal-features checks pass.cargo semver-checksvs 0.8.1 passes (the break is in animpl Traitargument bound, outside its lint coverage); declared in the changelog fragment.