Skip to content

Commit e0192cc

Browse files
authored
Fix a bug where the parent's timeout is not cancelled in RetryingClient (#5896)
Motivation: Originally, when a request is derived, the parent's timeout scheduler is not canceled. This was not a problem until the `ResponseTimeoutMode.FROM_START` feature was added because the timeout did not start at this point. When `ResponseTimeoutMode.FROM_START` is set, a timeout task is scheduled before it is derived. So it needs to be canceled. Otherwise, the uncanceled scheduler task may cause a leak because referenced objects are not GC'd until the task is completed. Modifications: - Cancel the timeout scheduler of the parent request when it is derived. Result: Fixed a bug where timeout tasks leak when using `ResponseTimeoutMode.FROM_START` with `RetryingClient`.
1 parent 257d204 commit e0192cc

File tree

5 files changed

+119
-0
lines changed

5 files changed

+119
-0
lines changed

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

+2
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,8 @@ private DefaultClientRequestContext(DefaultClientRequestContext ctx,
538538

539539
log = RequestLog.builder(this);
540540
log.startRequest();
541+
// Cancel the original timeout and create a new scheduler for the derived context.
542+
ctx.responseCancellationScheduler.cancelScheduled();
541543
responseCancellationScheduler =
542544
CancellationScheduler.ofClient(TimeUnit.MILLISECONDS.toNanos(ctx.responseTimeoutMillis()));
543545
writeTimeoutMillis = ctx.writeTimeoutMillis();

core/src/main/java/com/linecorp/armeria/internal/common/CancellationScheduler.java

+5
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@ static CancellationScheduler noop() {
8383
*/
8484
boolean cancelScheduled();
8585

86+
/**
87+
* Returns true if a timeout task is scheduled.
88+
*/
89+
boolean isScheduled();
90+
8691
void setTimeoutNanos(TimeoutMode mode, long timeoutNanos);
8792

8893
default void finishNow() {

core/src/main/java/com/linecorp/armeria/internal/common/DefaultCancellationScheduler.java

+5
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,11 @@ public boolean cancelScheduled() {
157157
}
158158
}
159159

160+
@Override
161+
public boolean isScheduled() {
162+
return scheduledFuture != null;
163+
}
164+
160165
@Override
161166
public void setTimeoutNanos(TimeoutMode mode, long timeoutNanos) {
162167
lock.lock();

core/src/main/java/com/linecorp/armeria/internal/common/NoopCancellationScheduler.java

+5
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ public boolean cancelScheduled() {
5757
return false;
5858
}
5959

60+
@Override
61+
public boolean isScheduled() {
62+
return false;
63+
}
64+
6065
@Override
6166
public void setTimeoutNanos(TimeoutMode mode, long timeoutNanos) {
6267
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/*
2+
* Copyright 2024 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 org.assertj.core.api.Assertions.assertThat;
20+
21+
import java.util.concurrent.atomic.AtomicInteger;
22+
import java.util.function.Function;
23+
24+
import org.junit.jupiter.api.BeforeEach;
25+
import org.junit.jupiter.api.Test;
26+
import org.junit.jupiter.api.extension.RegisterExtension;
27+
28+
import com.linecorp.armeria.client.BlockingWebClient;
29+
import com.linecorp.armeria.client.ClientRequestContext;
30+
import com.linecorp.armeria.client.ClientRequestContextCaptor;
31+
import com.linecorp.armeria.client.Clients;
32+
import com.linecorp.armeria.client.HttpClient;
33+
import com.linecorp.armeria.client.ResponseTimeoutMode;
34+
import com.linecorp.armeria.client.WebClient;
35+
import com.linecorp.armeria.common.AggregatedHttpResponse;
36+
import com.linecorp.armeria.common.HttpResponse;
37+
import com.linecorp.armeria.common.HttpStatus;
38+
import com.linecorp.armeria.common.logging.RequestLogAccess;
39+
import com.linecorp.armeria.internal.client.ClientRequestContextExtension;
40+
import com.linecorp.armeria.internal.common.CancellationScheduler;
41+
import com.linecorp.armeria.server.ServerBuilder;
42+
import com.linecorp.armeria.testing.junit5.server.ServerExtension;
43+
44+
class RetryTimeoutCancellationTest {
45+
46+
private static AtomicInteger counter = new AtomicInteger();
47+
48+
@RegisterExtension
49+
static ServerExtension server = new ServerExtension() {
50+
@Override
51+
protected void configure(ServerBuilder sb) {
52+
sb.service("/foo", (ctx, req) -> {
53+
return HttpResponse.of(req.aggregate().thenApply(unused -> {
54+
if (counter.getAndIncrement() < 2) {
55+
return HttpResponse.of(HttpStatus.INTERNAL_SERVER_ERROR);
56+
} else {
57+
return HttpResponse.of("hello");
58+
}
59+
}));
60+
});
61+
}
62+
};
63+
64+
@BeforeEach
65+
void setUp() {
66+
counter.set(0);
67+
}
68+
69+
@Test
70+
void shouldCancelTimoutScheduler() {
71+
final Function<? super HttpClient, RetryingClient> retryingClient =
72+
RetryingClient.builder(RetryRule.builder()
73+
.onServerErrorStatus()
74+
.thenBackoff(Backoff.fixed(100)))
75+
.maxTotalAttempts(3)
76+
.responseTimeoutMillisForEachAttempt(30_000)
77+
.newDecorator();
78+
final BlockingWebClient client = WebClient.builder(server.httpUri())
79+
.decorator(retryingClient)
80+
.responseTimeoutMode(ResponseTimeoutMode.FROM_START)
81+
.responseTimeoutMillis(80_000)
82+
.build()
83+
.blocking();
84+
85+
try (ClientRequestContextCaptor captor = Clients.newContextCaptor()) {
86+
final AggregatedHttpResponse res = client.post("/foo", "hello");
87+
final ClientRequestContext ctx = captor.get();
88+
assertThat(res.status()).isEqualTo(HttpStatus.OK);
89+
assertThat(res.contentUtf8()).isEqualTo("hello");
90+
assertTimeoutNotScheduled(ctx);
91+
for (RequestLogAccess child : ctx.log().children()) {
92+
assertTimeoutNotScheduled((ClientRequestContext) child.whenComplete().join().context());
93+
}
94+
}
95+
}
96+
97+
private static void assertTimeoutNotScheduled(ClientRequestContext ctx) {
98+
final CancellationScheduler scheduler = ctx.as(ClientRequestContextExtension.class)
99+
.responseCancellationScheduler();
100+
assertThat(scheduler.isScheduled()).isFalse();
101+
}
102+
}

0 commit comments

Comments
 (0)