Skip to content

perf(net): stop copying the response body up to four times - #166

Merged
cesarliws merged 1 commit into
dotpas:mainfrom
Stemonik:perf/restclient-response-copies
Jul 29, 2026
Merged

cesarliws merged 1 commit into
dotpas:mainfrom
Stemonik:perf/restclient-response-copies

Conversation

@Stemonik

Copy link
Copy Markdown
Contributor

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

1 the RTL (or Indy) buffers the payload unavoidable today
2 TDextHttpResponseImpl.Create copies it again removed
3 TRestResponse.Create copies into FRawContentStream kept — it is the raw content
4 TRestResponse.Create copies that into FContentStream, byte for byte, whenever the response is not compressed removed

Copy 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:

  • Indy allocated a TMemoryStream, let the response copy it, then freed it. Now the response adopts it (AOwnsStream = True) and Indy stops freeing it.
  • RTL exposes Response.ContentStream, owned by the IHTTPResponse that went out of scope at the end of Execute — which is why it was copied. Now the response holds the IHTTPResponse (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, FContentStream becomes a read-only view over the raw bytes:

TDextMemoryView = class(TCustomMemoryStream)  // points at FRawContentStream's memory

A view rather than the same object on purpose: ContentStream and RawContentStream keep 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:

allocated ratio
before 1,214,336 bytes 2.02x the payload
after 608,112 bytes 1.01x the payload

Verification

A harness exercised both branches against a live server and checked, for each:

  • body identical byte for byte to what the server sent;
  • ContentStream / RawContentStream positions still independent (moving one does not move the other);
  • re-reading a stream gives the same result (no destructive consumption);
  • gzip still decompresses — 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. TDextHttpResponseImpl is declared in the implementation section, and the altered TRestResponse fields are private. The only externally visible difference is that ContentStream is 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.

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.
@cesarliws
cesarliws merged commit 9bc6190 into dotpas:main Jul 29, 2026
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