Skip to content

Commit 88ec879

Browse files
authored
Fix a bug where a connection may be not reused when using RetryingClient (#5290)
Motivation: Armeria's `HttpChannelPool` is bound to an `EventLoop`. Different `EventLoop`s have different `HttpChannelPool`s. In other words, in order to reuse a connection for an `Endpoint`, the same `EventLoop` must be selected. When creating a derived client in `RetryingClient`, a new endpoint is selected for each retry, but since the `EventLoop` of the parent is used as is. That causes the `Endpoint` can't use the existing connection pool for multiplexing and makes a new connection. Modifications: - Use `EventLoopScheduler` to return constant `EventLoop`s for the same endpoint. - Allow setting `EndpointGroup` in `ClientRequestContextBuilder` for testability. Result: - You no longer see a connection leak when using `RetryingClient` with `EndpointGroup`.
1 parent 8b24aa0 commit 88ec879

File tree

3 files changed

+245
-2
lines changed

3 files changed

+245
-2
lines changed

core/src/main/java/com/linecorp/armeria/internal/client/DefaultClientRequestContext.java

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ private static SessionProtocol desiredSessionProtocol(SessionProtocol protocol,
180180
* e.g. {@code System.currentTimeMillis() * 1000}.
181181
*/
182182
public DefaultClientRequestContext(
183-
EventLoop eventLoop, MeterRegistry meterRegistry, SessionProtocol sessionProtocol,
183+
@Nullable EventLoop eventLoop, MeterRegistry meterRegistry, SessionProtocol sessionProtocol,
184184
RequestId id, HttpMethod method, RequestTarget reqTarget,
185185
ClientOptions options, @Nullable HttpRequest req, @Nullable RpcRequest rpcReq,
186186
RequestOptions requestOptions, CancellationScheduler responseCancellationScheduler,
@@ -511,7 +511,6 @@ private DefaultClientRequestContext(DefaultClientRequestContext ctx,
511511
// So we don't check the nullness of rpcRequest unlike request.
512512
// See https://github.com/line/armeria/pull/3251 and https://github.com/line/armeria/issues/3248.
513513

514-
eventLoop = ctx.eventLoop().withoutContext();
515514
options = ctx.options();
516515
root = ctx.root();
517516

@@ -531,6 +530,13 @@ private DefaultClientRequestContext(DefaultClientRequestContext ctx,
531530

532531
this.endpointGroup = endpointGroup;
533532
updateEndpoint(endpoint);
533+
// We don't need to acquire an EventLoop for the initial attempt because it's already acquired by
534+
// the root context.
535+
if (endpoint == null || ctx.endpoint() == endpoint && ctx.log.children().isEmpty()) {
536+
eventLoop = ctx.eventLoop().withoutContext();
537+
} else {
538+
acquireEventLoop(endpoint);
539+
}
534540
}
535541

536542
@Nullable
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/*
2+
* Copyright 2023 LINE Corporation
3+
*
4+
* LINE Corporation licenses this file to you under the Apache License,
5+
* version 2.0 (the "License"); you may not use this file except in compliance
6+
* with the License. You may obtain a copy of the License at:
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations
14+
* under the License.
15+
*/
16+
17+
package com.linecorp.armeria.client.retry;
18+
19+
import static com.google.common.collect.ImmutableList.toImmutableList;
20+
import static org.assertj.core.api.Assertions.assertThat;
21+
22+
import java.util.HashMap;
23+
import java.util.List;
24+
import java.util.Map;
25+
26+
import org.junit.jupiter.api.Test;
27+
import org.junit.jupiter.api.extension.RegisterExtension;
28+
29+
import com.linecorp.armeria.client.BlockingWebClient;
30+
import com.linecorp.armeria.client.ClientRequestContext;
31+
import com.linecorp.armeria.client.ClientRequestContextCaptor;
32+
import com.linecorp.armeria.client.Clients;
33+
import com.linecorp.armeria.client.Endpoint;
34+
import com.linecorp.armeria.client.WebClient;
35+
import com.linecorp.armeria.client.endpoint.EndpointGroup;
36+
import com.linecorp.armeria.common.AggregatedHttpResponse;
37+
import com.linecorp.armeria.common.HttpResponse;
38+
import com.linecorp.armeria.common.HttpStatus;
39+
import com.linecorp.armeria.common.SessionProtocol;
40+
import com.linecorp.armeria.common.logging.RequestLogAccess;
41+
import com.linecorp.armeria.internal.testing.AnticipatedException;
42+
import com.linecorp.armeria.server.ServerBuilder;
43+
import com.linecorp.armeria.testing.junit5.server.ServerExtension;
44+
45+
import io.netty.channel.EventLoop;
46+
47+
class RetryingClientEventLoopSchedulerTest {
48+
49+
@RegisterExtension
50+
static final ServerExtension server = new ServerExtension() {
51+
@Override
52+
protected void configure(ServerBuilder sb) {
53+
sb.http(0);
54+
sb.http(0);
55+
sb.http(0);
56+
sb.service("/fail", (ctx, req) -> {
57+
throw new AnticipatedException();
58+
});
59+
sb.service("/ok", (ctx, req) -> {
60+
return HttpResponse.of(200);
61+
});
62+
}
63+
};
64+
65+
@Test
66+
void shouldReturnCorrectEventLoop() {
67+
final List<Endpoint> endpoints = server.server().activePorts().values().stream()
68+
.map(port -> Endpoint.of(port.localAddress()))
69+
.collect(toImmutableList());
70+
assertThat(endpoints).hasSize(3);
71+
final Map<Endpoint, EventLoop> eventLoopMapping = new HashMap<>();
72+
73+
for (Endpoint endpoint : endpoints) {
74+
// Acquire the event loops for each endpoint.
75+
try (ClientRequestContextCaptor captor = Clients.newContextCaptor()) {
76+
final AggregatedHttpResponse res = WebClient.of(SessionProtocol.H2C, endpoint)
77+
.blocking()
78+
.get("/ok");
79+
assertThat(res.status()).isEqualTo(HttpStatus.OK);
80+
eventLoopMapping.put(endpoint, captor.get().eventLoop().withoutContext());
81+
}
82+
}
83+
84+
// Check that the event loops are correctly mapped for each attempt.
85+
final EndpointGroup endpointGroup = EndpointGroup.of(endpoints);
86+
final RetryRule retryRule = RetryRule.builder()
87+
.onServerErrorStatus()
88+
.thenBackoff(Backoff.withoutDelay());
89+
final BlockingWebClient client =
90+
WebClient.builder(SessionProtocol.H2C, endpointGroup)
91+
// Make retries until the maxTotalAttempts is reached.
92+
.responseTimeoutMillis(0)
93+
.decorator(RetryingClient.newDecorator(
94+
RetryConfig.builder(retryRule)
95+
.maxTotalAttempts(6)
96+
.build()))
97+
.build()
98+
.blocking();
99+
try (ClientRequestContextCaptor captor = Clients.newContextCaptor()) {
100+
assertThat(client.get("/fail").status()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
101+
final List<RequestLogAccess> children = captor.get().log().children();
102+
assertThat(children.size()).isEqualTo(6);
103+
for (int i = 0; i < 6; i++) {
104+
final ClientRequestContext childCtx = (ClientRequestContext) children.get(i).context();
105+
assertThat(childCtx.eventLoop().withoutContext())
106+
.isSameAs(eventLoopMapping.get(childCtx.endpoint()));
107+
}
108+
}
109+
}
110+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/*
2+
* Copyright 2023 LINE Corporation
3+
*
4+
* LINE Corporation licenses this file to you under the Apache License,
5+
* version 2.0 (the "License"); you may not use this file except in compliance
6+
* with the License. You may obtain a copy of the License at:
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations
14+
* under the License.
15+
*/
16+
17+
package com.linecorp.armeria.internal.client;
18+
19+
import static org.assertj.core.api.Assertions.assertThat;
20+
21+
import org.junit.jupiter.api.BeforeEach;
22+
import org.junit.jupiter.api.Test;
23+
24+
import com.linecorp.armeria.client.ClientOptions;
25+
import com.linecorp.armeria.client.ClientRequestContext;
26+
import com.linecorp.armeria.client.Endpoint;
27+
import com.linecorp.armeria.client.RequestOptions;
28+
import com.linecorp.armeria.client.endpoint.DynamicEndpointGroup;
29+
import com.linecorp.armeria.client.endpoint.EndpointSelectionStrategy;
30+
import com.linecorp.armeria.common.HttpMethod;
31+
import com.linecorp.armeria.common.HttpRequest;
32+
import com.linecorp.armeria.common.RequestId;
33+
import com.linecorp.armeria.common.RequestTarget;
34+
import com.linecorp.armeria.common.SessionProtocol;
35+
36+
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
37+
38+
class DerivedClientRequestContextClientTest {
39+
40+
private final Endpoint endpointA = Endpoint.of("a.com", 8080);
41+
private final Endpoint endpointB = Endpoint.of("b.com", 8080);
42+
private final Endpoint endpointC = Endpoint.of("c.com", 8080);
43+
private SettableEndpointGroup group;
44+
45+
@BeforeEach
46+
void setUp() {
47+
group = new SettableEndpointGroup();
48+
group.add(endpointA);
49+
group.add(endpointB);
50+
group.add(endpointC);
51+
}
52+
53+
@Test
54+
void shouldAcquireNewEventLoopForNewEndpoint() {
55+
final HttpRequest request = HttpRequest.of(HttpMethod.GET, "/");
56+
final DefaultClientRequestContext parent = new DefaultClientRequestContext(
57+
new SimpleMeterRegistry(), SessionProtocol.H2C, RequestId.random(), HttpMethod.GET,
58+
RequestTarget.forClient("/"), ClientOptions.of(), request, null, RequestOptions.of(), 0, 0);
59+
parent.init(group);
60+
assertThat(parent.endpoint()).isEqualTo(endpointA);
61+
final ClientRequestContext child =
62+
ClientUtil.newDerivedContext(parent, request, null, false);
63+
assertThat(child.endpoint()).isEqualTo(endpointB);
64+
assertThat(parent.endpoint()).isNotSameAs(child.endpoint());
65+
assertThat(parent.eventLoop().withoutContext()).isNotSameAs(child.eventLoop().withoutContext());
66+
}
67+
68+
@Test
69+
void shouldAcquireSameEventLoopForSameEndpoint() {
70+
final HttpRequest request = HttpRequest.of(HttpMethod.GET, "/");
71+
final DefaultClientRequestContext parent = new DefaultClientRequestContext(
72+
new SimpleMeterRegistry(), SessionProtocol.H2C, RequestId.random(), HttpMethod.GET,
73+
RequestTarget.forClient("/"), ClientOptions.of(), request, null, RequestOptions.of(), 0, 0);
74+
parent.init(group);
75+
assertThat(parent.endpoint()).isEqualTo(endpointA);
76+
final ClientRequestContext childA0 =
77+
ClientUtil.newDerivedContext(parent, HttpRequest.of(HttpMethod.GET, "/"), null, true);
78+
assertThat(childA0.endpoint()).isEqualTo(endpointA);
79+
final ClientRequestContext childB0 =
80+
ClientUtil.newDerivedContext(parent, HttpRequest.of(HttpMethod.GET, "/"), null, false);
81+
assertThat(childB0.endpoint()).isEqualTo(endpointB);
82+
final ClientRequestContext childC0 =
83+
ClientUtil.newDerivedContext(parent, HttpRequest.of(HttpMethod.GET, "/"), null, false);
84+
assertThat(childC0.endpoint()).isEqualTo(endpointC);
85+
86+
for (int i = 0; i < 3; i++) {
87+
final ClientRequestContext childA1 =
88+
ClientUtil.newDerivedContext(parent, HttpRequest.of(HttpMethod.GET, "/"), null, false);
89+
assertThat(childA1.endpoint()).isEqualTo(endpointA);
90+
assertThat(childA1.eventLoop().withoutContext()).isSameAs(childA0.eventLoop().withoutContext());
91+
final ClientRequestContext childB1 =
92+
ClientUtil.newDerivedContext(parent, HttpRequest.of(HttpMethod.GET, "/"), null, false);
93+
assertThat(childB1.endpoint()).isEqualTo(endpointB);
94+
assertThat(childB1.eventLoop().withoutContext()).isSameAs(childB0.eventLoop().withoutContext());
95+
final ClientRequestContext childC1 =
96+
ClientUtil.newDerivedContext(parent, HttpRequest.of(HttpMethod.GET, "/"), null, false);
97+
assertThat(childC1.endpoint()).isEqualTo(endpointC);
98+
assertThat(childC1.eventLoop().withoutContext()).isSameAs(childC0.eventLoop().withoutContext());
99+
}
100+
}
101+
102+
@Test
103+
void shouldNotAcquireNewEventLoopForInitialAttempt() {
104+
final HttpRequest request = HttpRequest.of(HttpMethod.GET, "/");
105+
final DefaultClientRequestContext parent = new DefaultClientRequestContext(
106+
new SimpleMeterRegistry(), SessionProtocol.H2C, RequestId.random(), HttpMethod.GET,
107+
RequestTarget.forClient("/"), ClientOptions.of(), request, null, RequestOptions.of(), 0, 0);
108+
parent.init(group);
109+
assertThat(parent.endpoint()).isEqualTo(endpointA);
110+
final ClientRequestContext child =
111+
ClientUtil.newDerivedContext(parent, HttpRequest.of(HttpMethod.GET, "/"), null, true);
112+
assertThat(child.endpoint()).isEqualTo(endpointA);
113+
assertThat(parent.endpoint()).isSameAs(child.endpoint());
114+
assertThat(parent.eventLoop().withoutContext()).isSameAs(child.eventLoop().withoutContext());
115+
}
116+
117+
private static class SettableEndpointGroup extends DynamicEndpointGroup {
118+
119+
SettableEndpointGroup() {
120+
super(EndpointSelectionStrategy.roundRobin());
121+
}
122+
123+
void add(Endpoint endpoint) {
124+
addEndpoint(endpoint);
125+
}
126+
}
127+
}

0 commit comments

Comments
 (0)