Skip to content

Commit 6d525c4

Browse files
jrhee17ikhoon
andcommitted
Provide a way to complete a ClientRequestContext in Preprocessor (line#6466)
Motivation: This issue was found while working on `GrpcServicesPreprocessor` refactoring. Currently, there is a bug where exceptions/response failures in `Preprocessor` do not complete `ctx.whenInitialized`. e.g. ``` WebClient.of((delegate, ctx, req) -> { // ctx.whenInitialized will not be completed throw new RuntimeException("error"); }).get("/"); ``` While normally this may not be an issue, gRPC relies on completion of this future to signal errors: ref: https://github.com/line/armeria/blob/1a31f3e8c9aba91fb663bfe5c4fc3eaa87c9d13f/grpc/src/main/java/com/linecorp/armeria/internal/client/grpc/ArmeriaClientCall.java#L180 I propose two modifications to resolve this issue: - When a Preprocessor throws an exception from the `PreClient#execute` calling thread, it should complete the ctx ``` WebClient.of((delegate, ctx, req) -> { throw new RuntimeException("error"); }).get("/"); ``` - When `ctx.cancel` is called (even before `ctx.init`) is called, it should complete the ctx ``` WebClient.of((delegate, ctx, req) -> { ctx.cancel(e); return HttpResponse.of(400); }).get("/"); ``` Modifications: - Introduced an `initialized` flag to guard against concurrent initialization attempts - `finishInitialization` is also modified to guard against concurrent calls - A new `initAndFail` method is introduced which acquires an event loop, initializes the cancellation scheduler, and completes the ctx - `ctx.cancel` will trigger `initAndFail` if the ctx is not initialized yet - If `PreClient#execute` throws an exception, `initAndFail` is called - For derived contexts, if an endpoint does not exist, it means `ClientUtil#initContextAnd*` needs to be called. Hence, initialization is set only if an endpoint exists. - `responseCancellationScheduler.finishNow` is called in `DefaultClientRequestContext#failEarly` to record `cancellationCause` consistently. - `XdsPreprocessor` and `RouterFilter` now calls `ctx.cancel` if a request is short-circuited before `ctx.init` is called. Result: - Requests failed at the `XdsPreprocessor`-level are propagated to the user correctly when using gRPC. <!-- Visit this URL to learn more about how to write a pull request description: https://armeria.dev/community/developer-guide#how-to-write-pull-request-description --> --------- Co-authored-by: Ikhun Um <ikhun.um@linecorp.com>
1 parent 36f4e38 commit 6d525c4

18 files changed

Lines changed: 397 additions & 36 deletions

File tree

core/src/main/java/com/linecorp/armeria/client/DefaultWebClient.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ public HttpResponse execute(HttpRequest req, RequestOptions requestOptions) {
126126
final DefaultClientRequestContext ctx = new DefaultClientRequestContext(
127127
protocol, newReq, newReq.method(), null, reqTarget, endpointGroup, requestOptions, options(),
128128
meterRegistry());
129-
return ClientUtil.executeWithFallback(preClient, ctx, newReq, errorResponseFactory());
129+
return ClientUtil.executePreClientWithFallback(preClient, ctx, newReq, errorResponseFactory());
130130
}
131131

132132
private static HttpResponse abortRequestAndReturnFailureResponse(

core/src/main/java/com/linecorp/armeria/client/retry/RetryingClient.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,14 +325,16 @@ private void doExecute0(ClientRequestContext ctx, HttpRequestDuplicator rootReqD
325325
assert ctxReq != null;
326326
final HttpResponse response;
327327
final ClientRequestContextExtension ctxExtension = derivedCtx.as(ClientRequestContextExtension.class);
328-
if (!initialAttempt && ctxExtension != null && derivedCtx.endpoint() == null) {
328+
if (!initialAttempt && ctxExtension != null && !ctxExtension.initializationTriggered()) {
329329
// clear the pending throwable to retry endpoint selection
330330
ClientPendingThrowableUtil.removePendingThrowable(derivedCtx);
331331
// if the endpoint hasn't been selected, try to initialize the ctx with a new endpoint/event loop
332+
// tryCompleteLog is false because we handle it in completeLogIfBytesNotTransferred.
332333
response = initContextAndExecuteWithFallback(
333334
unwrap(), ctxExtension, HttpResponse::of,
334335
(context, cause) -> HttpResponse.ofFailure(cause), ctxReq, false);
335336
} else {
337+
// tryCompleteLog is false because we handle it in completeLogIfBytesNotTransferred.
336338
response = executeWithFallback(unwrap(), derivedCtx,
337339
(context, cause) -> HttpResponse.ofFailure(cause), ctxReq, false);
338340
}

core/src/main/java/com/linecorp/armeria/client/retry/RetryingRpcClient.java

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@
2626
import com.linecorp.armeria.client.ResponseTimeoutException;
2727
import com.linecorp.armeria.client.RpcClient;
2828
import com.linecorp.armeria.client.UnprocessedRequestException;
29-
import com.linecorp.armeria.client.endpoint.EndpointGroup;
3029
import com.linecorp.armeria.common.HttpRequest;
3130
import com.linecorp.armeria.common.Request;
3231
import com.linecorp.armeria.common.RpcRequest;
@@ -184,9 +183,7 @@ private void doExecute0(ClientRequestContext ctx, RpcRequest req,
184183
final RpcResponse res;
185184

186185
final ClientRequestContextExtension ctxExtension = derivedCtx.as(ClientRequestContextExtension.class);
187-
final EndpointGroup endpointGroup = derivedCtx.endpointGroup();
188-
if (!initialAttempt && ctxExtension != null &&
189-
endpointGroup != null && derivedCtx.endpoint() == null) {
186+
if (!initialAttempt && ctxExtension != null && !ctxExtension.initializationTriggered()) {
190187
// clear the pending throwable to retry endpoint selection
191188
ClientPendingThrowableUtil.removePendingThrowable(derivedCtx);
192189
// if the endpoint hasn't been selected, try to initialize the ctx with a new endpoint/event loop

core/src/main/java/com/linecorp/armeria/common/CoreBlockHoundIntegration.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,5 +71,6 @@ public void applyTo(Builder builder) {
7171
builder.allowBlockingCallsInside("io.netty.handler.ssl.SslContext", "buildKeyStore");
7272
// StampedLock.writeLock() can be called
7373
builder.allowBlockingCallsInside("io.netty.buffer.AdaptivePoolingAllocator", "allocate");
74+
builder.allowBlockingCallsInside("com.linecorp.armeria.common.util.Version", "getAll");
7475
}
7576
}

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,18 @@ public interface ClientRequestContextExtension extends ClientRequestContext, Req
6565
*/
6666
void finishInitialization(boolean success);
6767

68+
/**
69+
* Initializes and fails the context immediately. This method can be thought of as a combination of
70+
* {@link #init()} and {@link #finishInitialization(boolean)}.
71+
* Returns true if this call initialized and failed the context.
72+
*/
73+
boolean initAndFail(Throwable cause);
74+
75+
/**
76+
* Unlike {@link #whenInitialized()}, returns whether an initialization is triggered.
77+
*/
78+
boolean initializationTriggered();
79+
6880
/**
6981
* A set of internal headers which are set by armeria internally.
7082
* These headers are merged with the lowest priority before getting sent over the wire.

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

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -169,19 +169,26 @@ O executeWithFallback(U delegate, ClientRequestContext ctx,
169169
}
170170

171171
public static <I extends Request, O extends Response, U extends PreClient<I, O>>
172-
O executeWithFallback(U execution,
173-
PreClientRequestContext ctx, I req,
174-
BiFunction<ClientRequestContext, Throwable, O> errorResponseFactory) {
172+
O executePreClientWithFallback(U execution,
173+
PreClientRequestContext ctx, I req,
174+
BiFunction<ClientRequestContext, Throwable, O> errorResponseFactory) {
175175
final ClientRequestContextExtension ctxExt = ctx.as(ClientRequestContextExtension.class);
176176
if (ctxExt != null) {
177177
ctxExt.runContextCustomizer();
178178
}
179+
O res;
179180
try {
180-
return execution.execute(ctx, req);
181+
res = execution.execute(ctx, req);
181182
} catch (Exception e) {
183+
if (ctxExt != null && !ctxExt.initializationTriggered()) {
184+
ctxExt.initAndFail(e);
185+
}
182186
fail(ctx, e);
183-
return errorResponseFactory.apply(ctx, e);
187+
res = errorResponseFactory.apply(ctx, e);
184188
}
189+
190+
completeLogIfIncomplete(ctx, res);
191+
return res;
185192
}
186193

187194
private static <I extends Request, O extends Response, U extends Client<I, O>>

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

Lines changed: 62 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import java.util.Objects;
3131
import java.util.concurrent.CompletableFuture;
3232
import java.util.concurrent.TimeUnit;
33+
import java.util.concurrent.atomic.AtomicIntegerFieldUpdater;
3334
import java.util.concurrent.atomic.AtomicReferenceFieldUpdater;
3435
import java.util.function.Consumer;
3536
import java.util.function.Function;
@@ -116,6 +117,10 @@ public final class DefaultClientRequestContext
116117
whenInitializedUpdater = AtomicReferenceFieldUpdater.newUpdater(
117118
DefaultClientRequestContext.class, CompletableFuture.class, "whenInitialized");
118119

120+
private static final AtomicIntegerFieldUpdater<DefaultClientRequestContext>
121+
initializationTriggeredUpdater = AtomicIntegerFieldUpdater.newUpdater(
122+
DefaultClientRequestContext.class, "initializationTriggered");
123+
119124
private static SessionProtocol desiredSessionProtocol(SessionProtocol protocol, ClientOptions options) {
120125
if (!options.factory().options().preferHttp1()) {
121126
return protocol;
@@ -134,7 +139,8 @@ private static SessionProtocol desiredSessionProtocol(SessionProtocol protocol,
134139
private static final short STR_PARENT_LOG_AVAILABILITY = 1 << 1;
135140
private static boolean warnedNullRequestId;
136141

137-
private boolean initialized;
142+
// 0: not initialized, 1: initialized
143+
private volatile int initializationTriggered;
138144
@Nullable
139145
private EventLoop eventLoop;
140146
private EndpointGroup endpointGroup;
@@ -358,9 +364,10 @@ private static ServiceRequestContext serviceRequestContext() {
358364

359365
@Override
360366
public CompletableFuture<Boolean> init() {
367+
if (!initializationTriggeredUpdater.compareAndSet(this, 0, 1)) {
368+
return whenInitialized();
369+
}
361370
assert endpoint == null : endpoint;
362-
assert !initialized;
363-
initialized = true;
364371

365372
final Throwable cancellationCause = cancellationCause();
366373
if (cancellationCause != null) {
@@ -383,6 +390,29 @@ public CompletableFuture<Boolean> init() {
383390
}
384391
}
385392

393+
@Override
394+
public boolean initAndFail(Throwable cause) {
395+
if (!initializationTriggeredUpdater.compareAndSet(this, 0, 1)) {
396+
return false;
397+
}
398+
acquireEventLoop(endpointGroup);
399+
failEarly(cause);
400+
finishInitialization(false);
401+
return true;
402+
}
403+
404+
private void initNow() {
405+
if (!initializationTriggeredUpdater.compareAndSet(this, 0, 1)) {
406+
return;
407+
}
408+
finishInitialization(false);
409+
}
410+
411+
@Override
412+
public boolean initializationTriggered() {
413+
return initializationTriggered == 1;
414+
}
415+
386416
private EndpointGroup mapEndpoint(EndpointGroup endpointGroup) {
387417
if (endpointGroup instanceof Endpoint) {
388418
return requireNonNull(options().endpointRemapper().apply((Endpoint) endpointGroup),
@@ -395,6 +425,7 @@ private EndpointGroup mapEndpoint(EndpointGroup endpointGroup) {
395425
private CompletableFuture<Boolean> initEndpoint(Endpoint endpoint) {
396426
updateEndpoint(endpoint);
397427
acquireEventLoop(endpoint);
428+
maybeInitializeResponseCancellationScheduler();
398429
return initFuture(true, null);
399430
}
400431

@@ -404,6 +435,7 @@ private CompletableFuture<Boolean> initEndpointGroup(EndpointGroup endpointGroup
404435
if (endpoint != null) {
405436
updateEndpoint(endpoint);
406437
acquireEventLoop(endpointGroup);
438+
maybeInitializeResponseCancellationScheduler();
407439
return initFuture(true, null);
408440
}
409441

@@ -412,6 +444,7 @@ private CompletableFuture<Boolean> initEndpointGroup(EndpointGroup endpointGroup
412444
return endpointGroup.select(this, temporaryEventLoop).handle((e, cause) -> {
413445
updateEndpoint(e);
414446
acquireEventLoop(endpointGroup);
447+
maybeInitializeResponseCancellationScheduler();
415448

416449
final boolean success;
417450
if (cause != null) {
@@ -462,13 +495,17 @@ public CompletableFuture<Boolean> whenInitialized() {
462495
public void finishInitialization(boolean success) {
463496
final CompletableFuture<Boolean> whenInitialized = this.whenInitialized;
464497
if (whenInitialized != null) {
465-
whenInitialized.complete(success);
498+
if (!whenInitialized.isDone()) {
499+
whenInitialized.complete(success);
500+
}
466501
} else {
467502
if (!whenInitializedUpdater.compareAndSet(this, null,
468503
UnmodifiableFuture.completedFuture(success))) {
469504
final CompletableFuture<Boolean> oldWhenInitialized = this.whenInitialized;
470505
assert oldWhenInitialized != null;
471-
oldWhenInitialized.complete(success);
506+
if (!oldWhenInitialized.isDone()) {
507+
oldWhenInitialized.complete(success);
508+
}
472509
}
473510
}
474511
}
@@ -484,7 +521,6 @@ private void acquireEventLoop(EndpointGroup endpointGroup) {
484521
options().factory().acquireEventLoop(sessionProtocol(), endpointGroup, endpoint);
485522
eventLoop = releasableEventLoop.get();
486523
log.whenComplete().thenAccept(unused -> releasableEventLoop.release());
487-
initializeResponseCancellationScheduler();
488524
}
489525
}
490526

@@ -540,6 +576,7 @@ private void failEarly(Throwable cause) {
540576
final RequestLogBuilder logBuilder = logBuilder();
541577
logBuilder.endRequest(wrapped);
542578
logBuilder.endResponse(wrapped);
579+
responseCancellationScheduler.finishNow(cause);
543580
}
544581

545582
// TODO(ikhoon): Consider moving the logic for filling authority to `HttpClientDelegate.exceute()`.
@@ -616,17 +653,23 @@ private DefaultClientRequestContext(DefaultClientRequestContext ctx,
616653

617654
this.endpointGroup = endpointGroup;
618655
updateEndpoint(endpoint);
656+
if (endpoint != null) {
657+
initNow();
658+
}
619659
// We don't need to acquire an EventLoop for the initial attempt because it's already acquired by
620660
// the root context.
621661
if (endpoint == null || ctx.endpoint() == endpoint && ctx.log.children().isEmpty()) {
622662
eventLoop = ctx.eventLoop().withoutContext();
623-
initializeResponseCancellationScheduler();
624663
} else {
625664
acquireEventLoop(endpoint);
626665
}
666+
maybeInitializeResponseCancellationScheduler();
627667
}
628668

629-
private void initializeResponseCancellationScheduler() {
669+
private void maybeInitializeResponseCancellationScheduler() {
670+
if (responseCancellationScheduler.hasEventLoop()) {
671+
return;
672+
}
630673
final CancellationTask cancellationTask = cause -> {
631674
try (SafeCloseable ignored = RequestContextUtil.pop()) {
632675
final HttpRequest request = request();
@@ -683,7 +726,7 @@ public SessionProtocol sessionProtocol() {
683726

684727
@Override
685728
public void setSessionProtocol(SessionProtocol sessionProtocol) {
686-
checkState(!initialized, "Cannot update sessionProtocol after initialization");
729+
checkState(!initializationTriggered(), "Cannot update sessionProtocol after initialization");
687730
this.sessionProtocol = desiredSessionProtocol(requireNonNull(sessionProtocol, "sessionProtocol"),
688731
options);
689732
}
@@ -789,10 +832,9 @@ public ContextAwareEventLoop eventLoop() {
789832

790833
@Override
791834
public void setEventLoop(EventLoop eventLoop) {
792-
checkState(!initialized, "Cannot update eventLoop after initialization");
835+
checkState(!initializationTriggered(), "Cannot update eventLoop after initialization");
793836
checkState(this.eventLoop == null, "eventLoop can be updated only once");
794837
this.eventLoop = requireNonNull(eventLoop, "eventLoop");
795-
initializeResponseCancellationScheduler();
796838
}
797839

798840
@Override
@@ -820,7 +862,7 @@ public EndpointGroup endpointGroup() {
820862

821863
@Override
822864
public void setEndpointGroup(EndpointGroup endpointGroup) {
823-
checkState(!initialized, "Cannot update endpointGroup after initialization");
865+
checkState(!initializationTriggered(), "Cannot update endpointGroup after initialization");
824866
this.endpointGroup = requireNonNull(endpointGroup, "endpointGroup");
825867
}
826868

@@ -1035,7 +1077,14 @@ public CancellationScheduler responseCancellationScheduler() {
10351077
@Override
10361078
public void cancel(Throwable cause) {
10371079
requireNonNull(cause, "cause");
1038-
responseCancellationScheduler.finishNow(cause);
1080+
if (initializationTriggered()) {
1081+
// happy path
1082+
responseCancellationScheduler.finishNow(cause);
1083+
return;
1084+
}
1085+
if (!initAndFail(cause)) {
1086+
responseCancellationScheduler.finishNow(cause);
1087+
}
10391088
}
10401089

10411090
@Nullable

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,6 @@ public O execute(PreClientRequestContext ctx, I req) {
7979
assert ctxExt != null;
8080
final Client<I, O> delegate0 = clientCustomizer.apply(ctxExt, delegate);
8181
return ClientUtil.initContextAndExecuteWithFallback(delegate0, ctxExt,
82-
futureConverter, errorResponseFactory, req, true);
82+
futureConverter, errorResponseFactory, req, false);
8383
}
8484
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,8 @@ default void finishNow() {
133133
@VisibleForTesting
134134
State state();
135135

136+
boolean hasEventLoop();
137+
136138
enum State {
137139
INIT,
138140
SCHEDULED,

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,11 @@ private ScheduleResult setTimeoutNanosFromNow0(long newTimeoutNanos) {
273273
return ScheduleResult.INVOKE_LATER;
274274
}
275275

276+
@Override
277+
public boolean hasEventLoop() {
278+
return eventLoop != null;
279+
}
280+
276281
private EventExecutor eventLoop() {
277282
assert eventLoop != null;
278283
return eventLoop;

0 commit comments

Comments
 (0)