Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BlockingStreamingHttpService: drop trailers if users didn't create any #3151

Merged
merged 6 commits into from
Dec 23, 2024

Conversation

idelpivnitskiy
Copy link
Member

@idelpivnitskiy idelpivnitskiy commented Dec 19, 2024

Motivation:

Behavior was discovered as part of debugging #3148.
Currently, BlockingStreamingHttpService allocates trailers upfront and concatenates them after the payload body when protocol allows. In result, aggregation of BlockingStreamingHttpResponse does not lead to a single HTTP/2 frame because we always expect to receive trailers. However, adding trailers after users close payload writer is race. There are no guarantees that new trailers will be written to the network after close. Therefore, we can inspect the state after close and decide if we need to append trailers or not.

Modifications:

  • Use scanWithMapper inside BlockingStreamingHttpService instead of always concatenating payload body with trailers;
  • Create trailers on demand inside BufferHttpPayloadWriter, only when users touch them;
  • Clarify behavior in HttpPayloadWriter's javadoc for trailers-related methods;
  • Enhance BlockingStreamingToStreamingServiceTest to assert new expectations;

Result:

In BlockingStreamingHttpService, trailers are allocated and attached only if users touch trailers before closing HttpPayloadWriter.

@idelpivnitskiy idelpivnitskiy changed the title BlockingStreamingHttpService: drop trailers if users didn't create any BlockingStreamingHttpService: drop trailers if users didn't create any Dec 20, 2024
@idelpivnitskiy idelpivnitskiy self-assigned this Dec 20, 2024
@idelpivnitskiy idelpivnitskiy marked this pull request as ready for review December 20, 2024 02:38
BlockingStreamingHttpService syncService = (ctx, request, response) -> {
HttpPayloadWriter<Buffer> writer = response.sendMetaData();
if (withEmptyTrailers) {
writer.trailers(); // accessing trailers before close should preserve trailers in message body
Copy link
Contributor

Choose a reason for hiding this comment

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

What's the use-case of empty trailers?

Copy link
Member Author

@idelpivnitskiy idelpivnitskiy Dec 20, 2024

Choose a reason for hiding this comment

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

Good question. Looking at it with a fresh morning look.

My original thinking was that if users didn't touch trailers at all, then it's absolutely safe to drop them. But if they did, they could do something like:

HttpHeaders trailers = writer.trailers();
....
trailers.add(name, value);

However, if they did that before close(), then we will see that. If they added smth after close(), it's anyway racy and not guaranteed.

Another thing is how we treat trailers in all other places:

  1. DefaultHttpRequest.toStreamingRequest() or DefaultHttpResponse.toStreamingResponse()

@Nullable
final Publisher<Object> payload;
if (trailers != null) {
payload = emptyPayloadBody ? from(trailers) : from(payloadBody, trailers);
} else {
payload = emptyPayloadBody ? null : from(payloadBody);
}

  1. HttpDataSourceTransformations.aggregatePayloadAndTrailers:

}).map(pair -> {
if (isAlwaysEmpty(pair.payload)) {
payloadInfo.setEmpty(true);
}
if (pair.trailers == null) {
payloadInfo.setMayHaveTrailersAndGenericTypeBuffer(false);
}
return pair;
});

Those account for the case when users can grab a reference to trailers, then convert the response and still use that reference to add trailers. Something like:

HttpResponse aggregatedResponse = ...;
HttpHeaders trailers = aggregatedResponse.trailers();
StreamingHttpResponse streamingResponse = aggregatedResponse.toStreamingResponse();
trailers.add(name, value);
return Single.succeeded(streamingResponse);

However, now I see that BlockingStreamingHttpServerResponse doesn't have conversion methods at all, and this trick won't be possible with HttpPayloadWriter anyway bcz we already sent meta-data.

The only real reason to keep it as a null check instead of "null or empty" is to keep it consistent with the above 2 cases. WDYT?

Copy link
Contributor

Choose a reason for hiding this comment

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

I think keeping the cases consistent makes sense but if there is a way to simplify I would prefer that.

Copy link
Member Author

Choose a reason for hiding this comment

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

+1 for starting in a consistent way and if we feel different, we can update them all together

Copy link
Contributor

Choose a reason for hiding this comment

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

Thank you for the explanation, makes sense to me to keep it consistent for now 👍

BlockingStreamingHttpService syncService = (ctx, request, response) -> {
HttpPayloadWriter<Buffer> writer = response.sendMetaData();
if (withEmptyTrailers) {
writer.trailers(); // accessing trailers before close should preserve trailers in message body
Copy link
Contributor

Choose a reason for hiding this comment

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

I think keeping the cases consistent makes sense but if there is a way to simplify I would prefer that.

@@ -245,7 +245,7 @@ public <T> HttpRequest payloadBody(final T pojo, final HttpSerializer2<T> serial
public HttpHeaders trailers() {
if (trailers == null) {
trailers = original.payloadHolder().headersFactory().newTrailers();
original.transform(this);
original.transform(this); // Invoke "transform" to set PayloadInfo.mayHaveTrailers() flag
Copy link
Contributor

Choose a reason for hiding this comment

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

this seemed odd to me and was one thing I had trouble grokking when I was trying to track this down.

Copy link
Member Author

Choose a reason for hiding this comment

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

I agree 😞
Unfortunately, we currently don't have a nice way to update PayloadInfo flags without invoking transformation that will do it for us. Maybe we refactor in the future.

@@ -197,11 +202,61 @@ public void close(final Throwable cause) throws IOException {

@Override
public HttpHeaders trailers() {
Copy link
Contributor

Choose a reason for hiding this comment

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

A "getter" method that also creates seems off to me. IDK if there is a better naming scheme where trailers0 is basically just trailers and trailers is getOrCreateTrailers.

Copy link
Member Author

Choose a reason for hiding this comment

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

It's "lazy get" 😄
We use the same trick in AbstractHttpMetaData.context(). When API is written in stone but we need to defer action.
From users point of view it will be non-visible change.

@idelpivnitskiy idelpivnitskiy merged commit f570fdb into apple:main Dec 23, 2024
11 checks passed
@idelpivnitskiy idelpivnitskiy deleted the blocking-streaming-trailers branch December 23, 2024 18:22
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.

3 participants