Support HTTP/1.1 request pipelining on the downstream session#876
Support HTTP/1.1 request pipelining on the downstream session#876CodyPubNub wants to merge 3 commits into
Conversation
Add opt-in HTTP/1.1 pipelining via HttpSession::set_pipelining_enabled(). When enabled, pipelined requests on a keep-alive connection are served sequentially in request order per RFC 9112 §9.3.2, matching nginx behavior on the same traffic shape. Default (off) is unchanged: the second pipelined request is dropped or surfaced as a 400 by the body-pump idle branch. Non-adopters are untouched: ServerSession::finish() and the H1 HttpSession::reuse() keep their pre-pipelining Result<Option<Stream>> signatures and discard any captured pipelined prefix. Adopters call finish_reuse() / reuse_pipelined() to receive ReusedHttpConnection. Covers both wire shapes: same-segment overread (both requests arrive in one read) via reuse_pipelined(), and two-segment overread (request N+1 arrives while request N's response is still being written) via read_body_or_idle()'s idle branch stashing non-zero reads into the body reader's overread surface. abort_on_close / half_closed are untouched so FIN handling is unchanged. HttpPersistentSettings carries pipelining_enabled + pipelined_prefix across keep-alive reuses; read_request() consumes the prefix first. Resolves cloudflare#377, cloudflare#673
Minimal ProxyHttp that opts in via set_pipelining_enabled(true) in early_request_filter. Matches the reproducer shape from cloudflare#377: pipelined GETs on one connection now both return responses.
|
This implementation is similar to something we were working on internally as well, I will pass it in for internal review so we can layer additional features on top. |
drcaramelsyrup
left a comment
There was a problem hiding this comment.
I think I'm fine with the overall direction here, but left some comments. I will see if others internally have opinions.
| // pipelining extraction. Crucially, we do NOT touch | ||
| // `half_closed` or `abort_on_close`; those control TCP FIN | ||
| // handling and must still abort immediately when the client | ||
| // disconnects. |
There was a problem hiding this comment.
Note that this comment seems slightly misleading, as the abort won't happen if any pipelined bytes are stashed (no 0 byte read can occur).
| .take() | ||
| .filter(|prefix| !prefix.is_empty()) | ||
| { | ||
| if buf.capacity() < prefix.len() { |
There was a problem hiding this comment.
The if seems unnecessary, the reserve will allocate if the additional bytes requested exceed capacity. I think a simple buf.reserve(prefix.len()) is what we want here.
| /// For H2, always return None because H2 stream is not reusable. | ||
| /// For subrequests, there is no true underlying stream to return. | ||
| pub async fn finish(self) -> Result<Option<Stream>> { | ||
| pub async fn finish_reuse(self) -> Result<Option<ReusedHttpConnection>> { |
There was a problem hiding this comment.
I think I would almost prefer removing finish_reuse and just making finish() return ReusedHttpConnection as a breaking API change as it seems like an easy footgun to drop the pipelined bytes without handling them otherwise.
I do not expect it to be a commonly used API either.
| use std::any::Any; | ||
| use std::time::Duration; | ||
|
|
||
| /// A reusable HTTP/1.x connection and bytes already read for the next request. |
There was a problem hiding this comment.
nit: I slightly prefer something like ReusableHttpStream even though it's very similar. I wonder if the current naming makes some arbitrary distinction between Stream and Connection that doesn't really hold here; Reusable implies that we will transform it into Reused.
Collapse `Session::finish()` and `HttpSession::reuse()` into a single pipelining-aware API so prefix bytes cannot be silently dropped. Rename `ReusedHttpConnection` to `ReusableHttpStream` to match the reusable to reused lifecycle, simplify the prefix `buf.reserve()` in `read_request()`, and tighten the `read_body_or_idle()` comment so it no longer implies `abort_on_close` can still fire after pipelined bytes are stashed.
|
Thank you for the feedback. I've addressed everything you've flagged. I'm looking forward to hearing any internal concerns, considerations, nitpicks, or otherwise that come up! |
Resolves #377, #673
What
Adds an opt-in HTTP/1.1 pipelining mode on the downstream session that serves pipelined requests sequentially in request order per RFC 9112 §9.3.2. Pipelining remains disabled by default.
HttpSession::set_pipelining_enabled(true)(mirrors the field+accessor shape of existing setters likekeepalive_timeout,abort_on_close,proxy_tasks_enabled). No newProxyHttptrait method.ServerSession::finish()and H1HttpSession::reuse()are pipelining-aware and return aReusableHttpStreamwhen the H1 connection can be reused. The wrapper carries the underlyingStreamplus any bytes already read for the next pipelined request;into_parts()remains the escape hatch.ReusableHttpStreamintoReusedHttpStreamwithReusedHttpStream::from_reusable_stream(), preserving the pipelined prefix inHttpPersistentSettingsfor the next session.Wire shapes covered
Both observed pipelining wire shapes are handled:
HttpSession::reuse()hands the body-reader overread back as the pipelined prefix for the next session.read_body_or_idle()'s idle branch stashes non-zero idle reads onto the body reader's overread surface instead of raisingConnectError("Sent data after end of body"). Theread == 0FIN path remains unchanged; this branch only handles non-zero pipelining bytes and leaveshalf_closed/abort_on_closeuntouched.HttpPersistentSettingscarriespipelining_enabled+ the prefix across keep-alive reuses;read_request()consumes the prefix first before pulling more bytes from the stream.Positioning
Restores nginx parity for the traffic shape in #377 without inheriting nginx's upstream re-pipelining behavior. Each pipelined request gets its own independent upstream selection and its response is fully written before the next request begins — matching Envoy, AWS CloudFront, Google Classic LB, Akamai, and IIS.
Tests
cargo test -p pingora-core test_pipelining— 12 unit tests covering:Content-Length: 0)read_request()consumes a complete prefix without touching the streamread_request()falls through to the stream for partial prefixesConnectErrorwhen pipelining is disabledtake_body_overread+set_pipelined_prefixPlus
pingora-proxy/examples/pipelining.rsthat opts in fromearly_request_filter. Minimal reproducer:Before this PR the same command returns a single
HTTP/1.1 200and the connection is marked un-reusable.Runtime cost for non-adopters
booland oneOption<BytesMut>onHttpSession(comparable scale to existing fields likeabort_on_close,proxy_tasks_enabled).self.pipelining_enabled && self.pipelined_idle_bytes_stashed(two bool reads) inside an already-async idle poll.HttpPersistentSettings, constructed once per keep-alive boundary.ProxyHttptrait.If a cargo feature gate is preferred (e.g. matching the
connection_filterpattern), happy to add one in a follow-up commit on this PR.