Skip to content

Decode a lone response body part in place - #2303

Open
pavel-ptashyts wants to merge 2 commits into
AsyncHttpClient:mainfrom
maygemdev:fix/decode-single-body-part-without-copy
Open

Decode a lone response body part in place#2303
pavel-ptashyts wants to merge 2 commits into
AsyncHttpClient:mainfrom
maygemdev:fix/decode-single-body-part-without-copy

Conversation

@pavel-ptashyts

Copy link
Copy Markdown
Contributor

Problem

For the default handler (executeRequest(request) -> AsyncCompletionHandlerBase
-> Response) the body is copied three times:

  1. HttpHandler.handleChunk -> EagerResponseBodyPart copies each chunk out of
    the network buffer into a heap byte[] (needed: channelRead releases the
    message afterwards);
  2. NettyResponse.getResponseBodyAsByteBuffer concatenates every part into a
    freshly allocated array;
  3. getResponseBody(charset) decodes that array.

Step 2 is pure waste when there is only one part, which is the case for any body
that lands in a single socket read: it copies a single part into a new array with
nothing to concatenate it with.

Change

getResponseBody(Charset) decodes straight from the part when there is exactly
one of them. The array does not escape the method, so the part's own array can be
decoded in place.

Several parts are still concatenated before decoding, never decoded one at a
time, because a multi-byte character can straddle a part boundary.

getResponseBodyAsBytes and getResponseBodyAsByteBuffer are deliberately left
untouched: they hand the array to the caller, so they keep making a defensive
copy rather than expose a part's own array. There is no aliasing change anywhere
in this PR.

Measurements

Rough probe on JDK 17 over a single-part ASCII body, concatenate-then-decode
versus decode-in-place. Not JMH, so read the shape rather than the digits:

body before after
512 B 496 ns 47 ns
4 KB 2277 ns 373 ns
16 KB 2963 ns 1450 ns
128 KB 24913 ns 12248 ns

Plus one fewer whole-body allocation per response. The percentages look large
partly because a pure-ASCII body decodes through a JDK intrinsic, which makes the
removed copy a big share of what is left.

Tests

Two added to NettyAsyncResponseTest:

  • testGetResponseBodyDecodesOnePartAndSplitPartsIdentically splits the two-byte
    UTF-8 encoding of U+00E9 across two parts and asserts one-part and split-part
    bodies decode alike. This pins the constraint the comment states: it fails if
    anyone later makes the multi-part path decode part by part.
  • testGetResponseBodyAsBytesDoesNotShareTheBodyPartArray pins that
    getResponseBodyAsBytes still returns a fresh array and never the part's own.

The body bytes are built as an explicit byte[] rather than a string literal to
keep the source ASCII per AGENTS.md.

Verification

mvnw clean verify - BUILD SUCCESS, 1373 tests (1371 before, plus these two),
0 failures, 0 errors, 19 skipped. Error Prone, NullAway and Revapi clean.
LargeResponseTest, NoNullResponseTest, BodyDeferringAsyncHandlerTest and
RedirectBodyTest, which exercise the multi-part path, are green.

Caveat on the testing gate: AGENTS.md requires the build to run on JDK 11 and
no JDK 11 is installed on this machine, so it was run on JDK 17 (also in the
CI matrix). The JDK 11 leg of CI on this PR is the real gate.

Not in scope

The multi-part case still concatenates. A CompositeByteBuf.toString(charset)
variant measured faster there (Netty decodes a multi-component buffer through a
recycled, un-zeroed thread-local array instead of a fresh byte[]), but it
regressed at high part counts in the same probe, so it needs proper benchmarking
before it becomes a change. Removing copy 1 would mean retaining network buffers
and giving Response a lifecycle, which is public API and wants a design
discussion first.

No public API change here. Same review pass as #2300, #2301 and #2302.

Claude Code on behalf of @pavel-ptashyts

🤖 Generated with Claude Code

Response#getResponseBody went through getResponseBodyAsBytes, which
concatenates every body part into a freshly allocated array. A body
that arrived in a single read was therefore copied twice: once to
concatenate a single part with nothing, and once to decode. That is the
common case for responses small enough to land in one socket read, and
it is the path the default AsyncCompletionHandlerBase puts every caller
of executeRequest(request) on.

Decode straight from the part when there is exactly one. A rough probe
on JDK 17 over a single-part ASCII body measured 496 -> 47 ns at 512 B,
2277 -> 373 ns at 4 KB, 2963 -> 1450 ns at 16 KB and 24913 -> 12248 ns
at 128 KB, alongside one fewer whole-body allocation.

Several parts are still concatenated before decoding, never decoded one
at a time, because a multi-byte character can straddle a part boundary.
The new test pins that by splitting a two-byte UTF-8 character across
two parts and asserting both shapes decode alike.

getResponseBodyAsBytes and getResponseBodyAsByteBuffer are deliberately
left alone: they hand the array to the caller, so they keep copying
rather than expose a part's own array. A second test pins that too.

Claude Code on behalf of Pavel Ptashyts

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment on lines +229 to +233
// Decode a lone body part straight from its own bytes. getResponseBodyAsBytes concatenates every part
// into a fresh array first, so a body that arrived in a single read was copied twice, once to
// concatenate and once to decode. The array does not escape this method, so decoding the part's own
// one is safe. Several parts are still concatenated before decoding rather than decoded one at a
// time, because a multi-byte character can straddle a part boundary.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Five lines of comment in a file that has none. The middle sentence is about what the old code did, which is commit message material. Two lines carry everything a reader needs:

// A lone part's array does not escape this method, so it can be decoded in place. Several parts are
// concatenated first because a multi-byte character can straddle a part boundary.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cut to two lines. The sentences about what the old code did went into the commit message, and what remains sits on the private helper, since that is where the branch and the constraint on it now live.

// concatenate and once to decode. The array does not escape this method, so decoding the part's own
// one is safe. Several parts are still concatenated before decoding rather than decoded one at a
// time, because a multi-byte character can straddle a part boundary.
if (bodyParts.size() == 1) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getResponseBodyAsStream a few lines below hands the same array to a ByteArrayInputStream, which never exposes what it wraps, so it can take this shortcut too. Fine to leave for a follow up. If you do both, put the guard in a small private helper instead of repeating it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did both. The guard is in a private sharedBodyBytes(), named to carry the constraint to the call sites: the array it returns may be a part's own, so only callers that keep it to themselves may use it. getResponseBodyAsBytes and getResponseBodyAsByteBuffer still copy.

int split = 4;

List<HttpResponseBodyPart> onePart = new LinkedList<>();
onePart.add(new EagerResponseBodyPart(Unpooled.wrappedBuffer(utf8), true));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both new tests use Eager parts. Lazy is the case where this is least obvious, since its getBodyPartBytes returns only the readable region rather than the whole backing array. Worth one more case with a Lazy part over a slice, say Unpooled.wrappedBuffer(backing, 3, 11), so that a later change to getBodyByteBuf().array() cannot slip through. testGetResponseBodyAsByteBuffer above already uses Lazy parts, so it fits the file.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added, with your wrappedBuffer(backing, 3, 11). Good catch. A version reaching for getBodyByteBuf().array() would read the surrounding bytes too, and nothing else in the suite would have noticed. The case asserts the stream path as well, now that it shares the shortcut.


// getResponseBody may decode a lone part in place, but getResponseBodyAsBytes hands the array to the
// caller, so it must keep copying rather than expose the part's own array.
assertNotSame(response.getResponseBodyAsBytes(), response.getResponseBodyAsBytes());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good one to have. This is what catches the tempting wrong version of this change, pushing the fast path down into getResponseBodyAsBytes, because Eager hands back the same array every time.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept for exactly that reason. Eager handing back the same array every time makes the wrong version of this look free.

Review feedback on AsyncHttpClient#2303. getResponseBodyAsStream wraps the bytes in a
ByteArrayInputStream, which never exposes the array it holds, so it can
read a lone part's own array for the same reason getResponseBody can.
Both now go through one private sharedBodyBytes(), which keeps the
guard and the constraint on it in a single place.

Cut the comment down. Two of its five lines described what the previous
version did, which belongs in a commit message, and the file carries no
other comments.

Add a case for a Lazy part over a slice. Lazy is where the shortcut is
least obvious, because its getBodyPartBytes returns only the readable
region rather than the whole backing array, so a later change reaching
for getBodyByteBuf().array() would read the surrounding bytes instead.
The case covers both the decode and the stream path.

Claude Code on behalf of Pavel Ptashyts

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

2 participants