Repository navigation
perf(net): stop copying the response body up to four times - #166
Merged
Merged
Conversation
A single HTTP response was buffered up to four times before the caller could
read it. For a 500 MB download that meant peaks well above a gigabyte, and
roughly 1 GB still alive for as long as the response was held.
Where the copies came from:
1. the RTL (or Indy) buffers the payload -- unavoidable today
2. TDextHttpResponseImpl.Create copied it again -- removed
3. TRestResponse.Create copied it into FRawContentStream
4. TRestResponse.Create copied THAT into FContentStream, byte for byte,
whenever the response was NOT compressed -- removed
Copy 2: the payload is already in a stream we can either adopt (Indy, which
allocated it and freed it right after the copy) or keep alive by holding the
IHTTPResponse (RTL). The constructor now takes ownership explicitly, so both
engines hand the same buffer over instead of duplicating it.
Copy 4: when there is no Content-Encoding, the decoded content IS the raw
content. It was duplicated for nothing. FContentStream is now a read-only view
over the raw bytes (TDextMemoryView, a TCustomMemoryStream pointing at the same
memory). A view rather than the same object on purpose: ContentStream and
RawContentStream keep independent positions, so callers that read both still
behave exactly as before. When the response IS gzip/deflate the decoded content
genuinely differs, and it still gets its own stream.
Measured against a real server, holding one 601 KB uncompressed response alive:
before 1,214,336 bytes 2.02x the payload
after 608,112 bytes 1.01x the payload
Behaviour is unchanged: same bytes, same independent positions, same result on
re-read, and the gzip path still decompresses. Verified on both the compressed
and uncompressed branches, plus a 429-test suite of a downstream project that
uses this client for every call -- all green, no leaks.
No public API changes: TDextHttpResponseImpl is implementation-only, and
TRestResponse's altered fields are private.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A single HTTP response was buffered up to four times before the caller could read it. On large downloads that is the difference between holding one copy of the payload and holding two, with a peak higher still.
Where the copies came from
TDextHttpResponseImpl.Createcopies it againTRestResponse.Createcopies intoFRawContentStreamTRestResponse.Createcopies that intoFContentStream, byte for byte, whenever the response is not compressedCopy 4 was the most wasteful: with no
Content-Encoding, the "decoded" content is the raw content. Both streams stayed alive for the lifetime of the response, holding identical bytes.What changed
Dext.Net.Engine.pas— copy 2. The payload is already sitting in a stream that we can either adopt or keep alive:TMemoryStream, let the response copy it, then freed it. Now the response adopts it (AOwnsStream = True) and Indy stops freeing it.Response.ContentStream, owned by theIHTTPResponsethat went out of scope at the end ofExecute— which is why it was copied. Now the response holds theIHTTPResponse(AKeepAlive), so the bytes stay valid without duplicating them.The constructor takes ownership explicitly, so there is no ambiguity about who frees what.
Dext.Net.RestClient.pas— copy 4. When the response is not compressed,FContentStreambecomes a read-only view over the raw bytes:A view rather than the same object on purpose:
ContentStreamandRawContentStreamkeep independent positions, so callers reading both behave exactly as before. When the response is gzip/deflate the decoded content genuinely differs, and it still gets its own stream.Measured
Holding one 601 KB uncompressed response alive, against a real server:
Verification
A harness exercised both branches against a live server and checked, for each:
ContentStream/RawContentStreampositions still independent (moving one does not move the other);raw 98,043 → decoded 601,263 bytes, with a real copy retained.Beyond that, a downstream project of mine drives this client on every call (VCL and web clients, file/BLOB round-trips comparing bytes one by one): its 429-test suite is green with no leaks, and its example applications build and run unchanged.
Compatibility
No public API changes.
TDextHttpResponseImplis declared in the implementation section, and the alteredTRestResponsefields are private. The only externally visible difference is thatContentStreamis now read-only when the response is uncompressed — writing to a response body was never meaningful, but it is worth flagging.One deliberate omission: the RTL still buffers the whole payload before we ever see it (copy 1). Removing that means streaming straight into a caller-supplied stream — which is what S58 is about, and I did not want to pre-empt the design you already outlined for it.