Skip to content

Commit 4b53081

Browse files
authored
feat(http/retry): model ReplayBody<B> with Frame<T> (#3598)
pr #3559 (dd4fbcd) refactored our trailer peeking body middleware to model its buffering in terms of the `Frame<T>` type used in `http-body`'s 1.0 release. this branch performs a similar change for the other piece of body middleware that super linkerd's retry facilities: `ReplayBody<B>`. the inner body `B` is now wrapped in the `ForwardCompatibleBody<B>` adapter, and we now poll it in terms of frames. NB: polling the underlying in terms of frames has a subtle knock-on effect regarding when we observe the trailers, in the liminal period between this refactor and the subsequent upgrade to hyper 1.0, whilst we must still implement the existing 0.4 interface for `Body` that includes `poll_trailers()`. see the comment above `replay_trailers` for more on this, describing why we now initialize this to `true`. relatedly, this is why we no longer delegate down to `B::poll_trailers` ourselves. it will have already been called by our adapter. `ReplayBody::is_end_stream()` now behaves identically when initially polling a body compared to subsequent replays. this is fine, as `is_end_stream()` is a hint that facilitates optimizations (hyperium/http-body#143). we do still report the end properly, we just won't be quite as prescient on the initial playthrough. in the same manner as the existing `frame()` method mimics `http_body_util::BodyExt::frame()`, this branch introduces a new `ForwardCompatibleBody::poll_frame()` method. this allows us to poll the compatibility layer for a `Frame<T>`. see: - linkerd/linkerd2#8733. - #3559 --- * nit(http/retry): install tracing subscriber in tests some tests do not set up a tracing subscriber, because they do not use the shared `Test::new()` helper function used elsewhere in this test suite. to provide a trace of the test's execution in the event of a failure, initialize a tracing subscriber in some additional unit tests. Signed-off-by: katelyn martin <[email protected]> * feat(http/retry): `ForwardCompatibleBody<B>` exposes hints this commit removes the `cfg(test)` gate on the method exposing `B::is_end_stream()`, and introduces another method also exposing the `size_hint()` method. we will want these in order to implement these methods for `ReplayBody<B>`. Signed-off-by: katelyn martin <[email protected]> * refactor(http/retry): `ForwardCompatibleBody::poll_frame()` in the same manner as the existing `frame()` method mimics `http_body_util::BodyExt::frame()`, this commit introduces a new `ForwardCompatibleBody::poll_frame()` method. this allows us to poll the compatibility layer for a `Frame<T>`. Signed-off-by: katelyn martin <[email protected]> * feat(http/retry): `ReplayBody<B>` polls for frames pr #3559 (dd4fbcd) refactored our trailer peeking body middleware to model its buffering in terms of the `Frame<T>` type used in `http-body`'s 1.0 release. this commit performs a similar change for the other piece of body middleware that super linkerd's retry facilities: `ReplayBody<B>`. the inner body `B` is now wrapped in the `ForwardCompatibleBody<B>` adapter, and we now poll it in terms of frames. NB: polling the underlying in terms of frames has a subtle knock-on effect regarding when we observe the trailers, in the liminal period between this refactor and the subsequent upgrade to hyper 1.0, whilst we must still implement the existing 0.4 interface for `Body` that includes `poll_trailers()`. see the comment above `replay_trailers` for more on this, describing why we now initialize this to `true`. relatedly, this is why we now longer delegate down to `B::poll_trailers` ourselves. it will have already been called by our adapter. `ReplayBody::is_end_stream()` now behaves identically when initially polling a body compared to subsequent replays. this is fine, as `is_end_stream()` is a hint that facilitates optimizations (hyperium/http-body#143). we do still report the end properly, we just won't be quite as prescient on the initial playthrough. see: - linkerd/linkerd2#8733. - #3559 Signed-off-by: katelyn martin <[email protected]> * feat(http/retry): `is_end_stream()` traces this commit introduces some trace-level diagnostics tracking how the replay body has determined whether or not it has reached the end of the stream. Signed-off-by: katelyn martin <[email protected]> * nit(http/retry): capitalize trace event messages Signed-off-by: katelyn martin <[email protected]> --------- Signed-off-by: katelyn martin <[email protected]>
1 parent c4e0fd2 commit 4b53081

File tree

3 files changed

+109
-27
lines changed

3 files changed

+109
-27
lines changed

linkerd/http/retry/src/compat.rs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
//! Compatibility utilities for upgrading to http-body 1.0.
22
3-
use http_body::Body;
3+
use http_body::{Body, SizeHint};
4+
use std::{
5+
future::Future,
6+
pin::Pin,
7+
task::{Context, Poll},
8+
};
49

510
pub(crate) use self::frame::Frame;
611

@@ -42,10 +47,25 @@ impl<B: Body> ForwardCompatibleBody<B> {
4247
}
4348

4449
/// Returns `true` when the end of stream has been reached.
45-
#[cfg(test)]
4650
pub(crate) fn is_end_stream(&self) -> bool {
4751
self.inner.is_end_stream()
4852
}
53+
54+
/// Returns the bounds on the remaining length of the stream.
55+
pub(crate) fn size_hint(&self) -> SizeHint {
56+
self.inner.size_hint()
57+
}
58+
}
59+
60+
impl<B: Body + Unpin> ForwardCompatibleBody<B> {
61+
pub(crate) fn poll_frame(
62+
self: Pin<&mut Self>,
63+
cx: &mut Context<'_>,
64+
) -> Poll<Option<Result<Frame<B::Data>, B::Error>>> {
65+
let mut fut = self.get_mut().frame();
66+
let pinned = Pin::new(&mut fut);
67+
pinned.poll(cx)
68+
}
4969
}
5070

5171
/// Future that resolves to the next frame from a `Body`.

linkerd/http/retry/src/replay.rs

Lines changed: 74 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ struct SharedState<B> {
6868
struct BodyState<B> {
6969
replay: Replay,
7070
trailers: Option<HeaderMap>,
71-
rest: B,
71+
rest: crate::compat::ForwardCompatibleBody<B>,
7272
is_completed: bool,
7373

7474
/// Maximum number of bytes to buffer.
@@ -104,13 +104,19 @@ impl<B: Body> ReplayBody<B> {
104104
state: Some(BodyState {
105105
replay: Default::default(),
106106
trailers: None,
107-
rest: body,
107+
rest: crate::compat::ForwardCompatibleBody::new(body),
108108
is_completed: false,
109109
max_bytes: max_bytes + 1,
110110
}),
111-
// The initial `ReplayBody` has nothing to replay
111+
// The initial `ReplayBody` has no data to replay.
112112
replay_body: false,
113-
replay_trailers: false,
113+
// NOTE(kate): When polling the inner body in terms of frames, we will not yield
114+
// `Ready(None)` from `Body::poll_data()` until we have reached the end of the
115+
// underlying stream. Once we have migrated to `http-body` v1, this field will be
116+
// initialized `false` thanks to the use of `Body::poll_frame()`, but for now we must
117+
// initialize this to true; `poll_trailers()` will be called after the trailers have
118+
// been observed previously, even for the initial body.
119+
replay_trailers: true,
114120
})
115121
}
116122

@@ -204,16 +210,33 @@ where
204210
// Poll the inner body for more data. If the body has ended, remember
205211
// that so that future clones will not try polling it again (as
206212
// described above).
207-
let data = {
213+
let data: B::Data = {
214+
use futures::{future::Either, ready};
215+
// Poll the inner body for the next frame.
208216
tracing::trace!("Polling initial body");
209-
match futures::ready!(Pin::new(&mut state.rest).poll_data(cx)) {
210-
Some(Ok(data)) => data,
211-
Some(Err(e)) => return Poll::Ready(Some(Err(e.into()))),
217+
let poll = Pin::new(&mut state.rest).poll_frame(cx).map_err(Into::into);
218+
let frame = match ready!(poll) {
219+
// The body yielded a new frame.
220+
Some(Ok(frame)) => frame,
221+
// The body yielded an error.
222+
Some(Err(error)) => return Poll::Ready(Some(Err(error))),
223+
// The body has reached the end of the stream.
212224
None => {
213225
tracing::trace!("Initial body completed");
214226
state.is_completed = true;
215227
return Poll::Ready(None);
216228
}
229+
};
230+
// Now, inspect the frame: was it a chunk of data, or a trailers frame?
231+
match Self::split_frame(frame) {
232+
Some(Either::Left(data)) => data,
233+
Some(Either::Right(trailers)) => {
234+
tracing::trace!("Initial body completed");
235+
state.trailers = Some(trailers);
236+
state.is_completed = true;
237+
return Poll::Ready(None);
238+
}
239+
None => return Poll::Ready(None),
217240
}
218241
};
219242

@@ -234,7 +257,7 @@ where
234257
/// NOT be polled until the previous body has been dropped.
235258
fn poll_trailers(
236259
self: Pin<&mut Self>,
237-
cx: &mut Context<'_>,
260+
_cx: &mut Context<'_>,
238261
) -> Poll<Result<Option<HeaderMap>, Self::Error>> {
239262
let this = self.get_mut();
240263
let state = Self::acquire_state(&mut this.state, &this.shared.body);
@@ -251,40 +274,40 @@ where
251274
}
252275
}
253276

254-
// If the inner body has previously ended, don't poll it again.
255-
if !state.rest.is_end_stream() {
256-
return Pin::new(&mut state.rest)
257-
.poll_trailers(cx)
258-
.map_ok(|tlrs| {
259-
// Record a copy of the inner body's trailers in the shared state.
260-
if state.trailers.is_none() {
261-
state.trailers.clone_from(&tlrs);
262-
}
263-
tlrs
264-
})
265-
.map_err(Into::into);
266-
}
267-
268277
Poll::Ready(Ok(None))
269278
}
270279

280+
#[tracing::instrument(
281+
skip_all,
282+
level = "trace",
283+
fields(
284+
state.is_some = %self.state.is_some(),
285+
replay_trailers = %self.replay_trailers,
286+
replay_body = %self.replay_body,
287+
is_completed = ?self.state.as_ref().map(|s| s.is_completed),
288+
)
289+
)]
271290
fn is_end_stream(&self) -> bool {
272291
// If the initial body was empty as soon as it was wrapped, then we are finished.
273292
if self.shared.was_empty {
293+
tracing::trace!("Initial body was empty, stream has ended");
274294
return true;
275295
}
276296

277297
let Some(state) = self.state.as_ref() else {
278298
// This body is not currently the "active" replay being polled.
299+
tracing::trace!("Inactive replay body is not complete");
279300
return false;
280301
};
281302

282303
// if this body has data or trailers remaining to play back, it
283304
// is not EOS
284-
!self.replay_body && !self.replay_trailers
305+
let eos = !self.replay_body && !self.replay_trailers
285306
// if we have replayed everything, the initial body may
286307
// still have data remaining, so ask it
287-
&& state.rest.is_end_stream()
308+
&& state.rest.is_end_stream();
309+
tracing::trace!(%eos, "Checked replay body end-of-stream");
310+
eos
288311
}
289312

290313
#[inline]
@@ -334,6 +357,32 @@ impl<B> Drop for ReplayBody<B> {
334357
}
335358
}
336359

360+
impl<B: Body> ReplayBody<B> {
361+
/// Splits a `Frame<T>` into a chunk of data or a header map.
362+
///
363+
/// Frames do not expose their inner enums, and instead expose `into_data()` and
364+
/// `into_trailers()` methods. This function breaks the frame into either `Some(Left(data))`
365+
/// if it is given a DATA frame, and `Some(Right(trailers))` if it is given a TRAILERS frame.
366+
///
367+
/// This returns `None` if an unknown frame is provided, that is neither.
368+
///
369+
/// This is an internal helper to facilitate pattern matching in `read_body(..)`, above.
370+
fn split_frame(
371+
frame: crate::compat::Frame<B::Data>,
372+
) -> Option<futures::future::Either<B::Data, HeaderMap>> {
373+
use {crate::compat::Frame, futures::future::Either};
374+
match frame.into_data().map_err(Frame::into_trailers) {
375+
Ok(data) => Some(Either::Left(data)),
376+
Err(Ok(trailers)) => Some(Either::Right(trailers)),
377+
Err(Err(_unknown)) => {
378+
// It's possible that some sort of unknown frame could be encountered.
379+
tracing::warn!("An unknown body frame has been buffered");
380+
None
381+
}
382+
}
383+
}
384+
}
385+
337386
// === impl BodyState ===
338387

339388
impl<B> BodyState<B> {

linkerd/http/retry/src/replay/tests.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,7 @@ fn empty_body_is_always_eos() {
327327
async fn eos_only_when_fully_replayed() {
328328
// Test that each clone of a body is not EOS until the data has been
329329
// fully replayed.
330+
let _trace = linkerd_tracing::test::with_default_filter("linkerd_http_retry=trace");
330331
let initial = ReplayBody::try_new(TestBody::one_data_frame(), 64 * 1024)
331332
.expect("body must not be too large");
332333
let replay = initial.clone();
@@ -344,6 +345,8 @@ async fn eos_only_when_fully_replayed() {
344345
.expect("yields a frame")
345346
.into_data()
346347
.expect("yields a data frame");
348+
// TODO(kate): the initial body doesn't report ending until it has (not) yielded trailers.
349+
assert!(initial.frame().await.is_none());
347350
assert!(initial.is_end_stream());
348351
assert!(!replay.is_end_stream());
349352
drop(initial);
@@ -388,6 +391,7 @@ async fn eos_only_when_fully_replayed() {
388391
async fn eos_only_when_fully_replayed_with_trailers() {
389392
// Test that each clone of a body is not EOS until the data has been
390393
// fully replayed.
394+
let _trace = linkerd_tracing::test::with_default_filter("linkerd_http_retry=trace");
391395
let initial = ReplayBody::try_new(TestBody::one_data_frame().with_trailers(), 64 * 1024)
392396
.expect("body must not be too large");
393397
let replay = initial.clone();
@@ -561,6 +565,7 @@ async fn caps_across_replays() {
561565

562566
#[test]
563567
fn body_too_big() {
568+
let _trace = linkerd_tracing::test::with_default_filter("linkerd_http_retry=trace");
564569
let max_size = 8;
565570
let mk_body = |sz: usize| -> BoxBody {
566571
let s = (0..sz).map(|_| "x").collect::<String>();
@@ -597,6 +602,7 @@ fn body_too_big() {
597602
#[allow(clippy::redundant_clone)]
598603
#[test]
599604
fn size_hint_is_correct_for_empty_body() {
605+
let _trace = linkerd_tracing::test::with_default_filter("linkerd_http_retry=trace");
600606
let initial =
601607
ReplayBody::try_new(BoxBody::empty(), 64 * 1024).expect("empty body can't be too large");
602608
let size = initial.size_hint();
@@ -617,6 +623,7 @@ async fn size_hint_is_correct_across_replays() {
617623
debug_assert!(SIZE as usize <= CAPACITY);
618624

619625
// Create the initial body, and a replay.
626+
let _trace = linkerd_tracing::test::with_default_filter("linkerd_http_retry=trace");
620627
let mut initial = ReplayBody::try_new(BoxBody::from_static(BODY), CAPACITY)
621628
.expect("empty body can't be too large");
622629
let mut replay = initial.clone();
@@ -629,6 +636,12 @@ async fn size_hint_is_correct_across_replays() {
629636

630637
// Read the body, check the size hint again.
631638
assert_eq!(chunk(&mut initial).await.as_deref(), Some(BODY));
639+
let initial = {
640+
// TODO(kate): the initial body doesn't report ending until it has (not) yielded trailers.
641+
let mut body = crate::compat::ForwardCompatibleBody::new(initial);
642+
assert!(body.frame().await.is_none());
643+
body.into_inner()
644+
};
632645
debug_assert!(initial.is_end_stream());
633646
// TODO(kate): this currently misreports the *remaining* size of the body.
634647
// let size = initial.size_hint();

0 commit comments

Comments
 (0)