Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions api/src/main/java/io/grpc/ClientStreamTracer.java
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,15 @@ public void inboundTrailers(Metadata trailers) {
public void addOptionalLabel(String key, String value) {
}

/**
* The stream was cancelled from the client side before a normal response was received.
*
* @param status the cancellation status
* @since 1.84.0
*/
public void cancelled(Status status) {
}

/**
* Factory class for {@link ClientStreamTracer}.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ public final void halfClose() {
public final void cancel(Status reason) {
Preconditions.checkArgument(!reason.isOk(), "Should not cancel with OK status");
cancelled = true;
transportState().getStatsTraceContext().clientCancelled(reason);
abstractClientStreamSink().cancel(reason);
}

Expand Down
7 changes: 7 additions & 0 deletions core/src/main/java/io/grpc/internal/FailingClientStream.java
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ public void start(ClientStreamListener listener) {
listener.closed(error, rpcProgress, new Metadata());
}

@Override
public void cancel(Status reason) {
for (ClientStreamTracer tracer : tracers) {
tracer.cancelled(reason);
}
}

@VisibleForTesting
Status getError() {
return error;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ public void addOptionalLabel(String key, String value) {
delegate().addOptionalLabel(key, value);
}

@Override
public void cancelled(Status status) {
delegate().cancelled(status);
}

@Override
public void streamClosed(Status status) {
delegate().streamClosed(status);
Expand Down
13 changes: 13 additions & 0 deletions core/src/main/java/io/grpc/internal/StatsTraceContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,19 @@ public void serverCallMethodResolved(MethodDescriptor<?, ?> method) {
}
}

/**
* See {@link ClientStreamTracer#cancelled}. For client-side only.
*
* <p>Called from abstract stream implementations.
*/
public void clientCancelled(Status status) {
for (StreamTracer tracer : tracers) {
if (tracer instanceof ClientStreamTracer) {
((ClientStreamTracer) tracer).cancelled(status);
}
}
}

/**
* See {@link StreamTracer#streamClosed}. This may be called multiple times, and only the first
* value will be taken.
Expand Down
19 changes: 19 additions & 0 deletions core/src/test/java/io/grpc/internal/AbstractClientStreamTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@

import io.grpc.Attributes;
import io.grpc.CallOptions;
import io.grpc.ClientStreamTracer;
import io.grpc.Codec;
import io.grpc.Deadline;
import io.grpc.Grpc;
Expand Down Expand Up @@ -155,6 +156,24 @@ public void cancel(Status errorStatus) {
verify(mockListener).closed(any(Status.class), same(PROCESSED), any(Metadata.class));
}

@Test
public void cancel_notifiesStatsTraceContext() {
ClientStreamTracer mockTracer = mock(ClientStreamTracer.class);
StatsTraceContext customStatsTraceCtx = new StatsTraceContext(new StreamTracer[] {mockTracer});
final BaseTransportState state = new BaseTransportState(customStatsTraceCtx, transportTracer);
AbstractClientStream stream = new BaseAbstractClientStream(allocator, state, new BaseSink() {
@Override
public void cancel(Status errorStatus) {
}
}, customStatsTraceCtx, transportTracer);
stream.start(mockListener);

Status cancelStatus = Status.CANCELLED.withDescription("Cancelled by test");
stream.cancel(cancelStatus);

verify(mockTracer).cancelled(cancelStatus);
}

@Test
public void startFailsOnNullListener() {
AbstractClientStream stream =
Expand Down
10 changes: 10 additions & 0 deletions core/src/test/java/io/grpc/internal/FailingClientStreamTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,14 @@ public void droppedRpcProgressPopulatedToListener() {
stream.start(listener);
verify(listener).closed(eq(status), eq(RpcProgress.DROPPED), any(Metadata.class));
}

@Test
public void cancel_notifiesTracers() {
ClientStreamTracer mockTracer = mock(ClientStreamTracer.class);
ClientStream stream = new FailingClientStream(
Status.UNAVAILABLE, RpcProgress.PROCESSED, new ClientStreamTracer[] {mockTracer});
Status cancelStatus = Status.CANCELLED.withDescription("Cancelled by test");
stream.cancel(cancelStatus);
verify(mockTracer).cancelled(cancelStatus);
}
}
49 changes: 49 additions & 0 deletions core/src/test/java/io/grpc/internal/StatsTraceContextTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* Copyright 2026 The gRPC Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.grpc.internal;

import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;

import io.grpc.ClientStreamTracer;
import io.grpc.ServerStreamTracer;
import io.grpc.Status;
import io.grpc.StreamTracer;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;

/** Unit tests for {@link StatsTraceContext}. */
@RunWith(JUnit4.class)
public class StatsTraceContextTest {

@Test
public void clientCancelled_notifiesClientStreamTracers() {
ClientStreamTracer clientTracer = mock(ClientStreamTracer.class);
ServerStreamTracer serverTracer = mock(ServerStreamTracer.class);

StatsTraceContext statsTraceCtx = new StatsTraceContext(
new StreamTracer[] {clientTracer, serverTracer});

Status cancelledStatus = Status.CANCELLED.withDescription("Client cancelled");
statsTraceCtx.clientCancelled(cancelledStatus);

verify(clientTracer).cancelled(cancelledStatus);
verifyNoInteractions(serverTracer);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ public void addOptionalLabel(String key, String value) {
delegate().addOptionalLabel(key, value);
}

@Override
public void cancelled(Status status) {
delegate().cancelled(status);
}

@Override
public void streamClosed(Status status) {
delegate().streamClosed(status);
Expand Down
23 changes: 21 additions & 2 deletions util/src/main/java/io/grpc/util/OutlierDetectionLoadBalancer.java
Original file line number Diff line number Diff line change
Expand Up @@ -477,22 +477,41 @@ public ClientStreamTracer newClientStreamTracer(StreamInfo info, Metadata header
if (delegateFactory != null) {
ClientStreamTracer delegateTracer = delegateFactory.newClientStreamTracer(info, headers);
return new ForwardingClientStreamTracer() {
private volatile boolean cancelled;

@Override
protected ClientStreamTracer delegate() {
return delegateTracer;
}

@Override
public void cancelled(Status status) {
cancelled = true;
delegate().cancelled(status);
}

@Override
public void streamClosed(Status status) {
tracker.incrementCallCount(status.isOk());
if (!cancelled) {
tracker.incrementCallCount(status.isOk());
}
delegate().streamClosed(status);
}
};
} else {
return new ClientStreamTracer() {
private volatile boolean cancelled;

@Override
public void cancelled(Status status) {
cancelled = true;
}

@Override
public void streamClosed(Status status) {
tracker.incrementCallCount(status.isOk());
if (!cancelled) {
tracker.incrementCallCount(status.isOk());
}
}
};
}
Expand Down
114 changes: 114 additions & 0 deletions util/src/test/java/io/grpc/util/OutlierDetectionLoadBalancerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,29 @@ public void delegatePickTracerFactoryPreserved() {
verify(mockStreamTracer).inboundHeaders();
}

@Test
public void delegatePick_cancelledForwarded() {
OutlierDetectionLoadBalancerConfig config = new OutlierDetectionLoadBalancerConfig.Builder()
.setSuccessRateEjection(new SuccessRateEjection.Builder().build())
.setChildConfig(newChildConfig(fakeLbProvider, null)).build();

loadBalancer.acceptResolvedAddresses(buildResolvedAddress(config, servers.get(0)));

final Subchannel readySubchannel = subchannels.values().iterator().next();
deliverSubchannelState(readySubchannel, ConnectivityStateInfo.forNonError(READY));

verify(mockHelper, times(2)).updateBalancingState(stateCaptor.capture(),
pickerCaptor.capture());

SubchannelPicker picker = pickerCaptor.getAllValues().get(1);
PickResult pickResult = picker.pickSubchannel(mock(PickSubchannelArgs.class));

ClientStreamTracer clientStreamTracer = pickResult.getStreamTracerFactory()
.newClientStreamTracer(ClientStreamTracer.StreamInfo.newBuilder().build(), new Metadata());
clientStreamTracer.cancelled(Status.CANCELLED);
verify(mockStreamTracer).cancelled(Status.CANCELLED);
}

/**
* Assure the tracer works even when the underlying LB does not have a tracer to delegate to.
*/
Expand Down Expand Up @@ -531,6 +554,51 @@ public void successRateOneOutlier() {
assertEjectedSubchannels(ImmutableSet.of(ImmutableSet.copyOf(servers.get(0).getAddresses())));
}

/**
* Client-cancelled streams (e.g. non-winning hedged attempts) do not count as failures.
*/
@Test
public void successRate_clientCancelled_notEjected() {
OutlierDetectionLoadBalancerConfig config = new OutlierDetectionLoadBalancerConfig.Builder()
.setMaxEjectionPercent(50)
.setSuccessRateEjection(
new SuccessRateEjection.Builder()
.setMinimumHosts(3)
.setRequestVolume(10).build())
.setChildConfig(newChildConfig(roundRobinLbProvider, null)).build();

loadBalancer.acceptResolvedAddresses(buildResolvedAddress(config, servers));

deliverSubchannelState(subchannel1, ConnectivityStateInfo.forNonError(READY));
deliverSubchannelState(subchannel2, ConnectivityStateInfo.forNonError(READY));
deliverSubchannelState(subchannel3, ConnectivityStateInfo.forNonError(READY));
deliverSubchannelState(subchannel4, ConnectivityStateInfo.forNonError(READY));
deliverSubchannelState(subchannel5, ConnectivityStateInfo.forNonError(READY));

verify(mockHelper, times(7)).updateBalancingState(stateCaptor.capture(),
pickerCaptor.capture());
SubchannelPicker picker = pickerCaptor.getAllValues()
.get(pickerCaptor.getAllValues().size() - 1);

for (int i = 0; i < 100; i++) {
PickResult pickResult = picker.pickSubchannel(mock(PickSubchannelArgs.class));
ClientStreamTracer clientStreamTracer = pickResult.getStreamTracerFactory()
.newClientStreamTracer(null, null);
Subchannel subchannel = (Subchannel) pickResult.getSubchannel().getInternalSubchannel();
if (subchannel == subchannel1) {
clientStreamTracer.cancelled(Status.CANCELLED);
clientStreamTracer.streamClosed(Status.CANCELLED);
} else {
clientStreamTracer.streamClosed(Status.OK);
}
}

forwardTime(config);

// subchannel1 was cancelled client-side and should not be ejected as an outlier.
assertEjectedSubchannels(ImmutableSet.of());
}

/**
* The success rate algorithm ejects the outlier, but then the config changes so that similar
* behavior no longer gets ejected.
Expand Down Expand Up @@ -781,6 +849,52 @@ public void failurePercentageNoOutliers() {
assertEjectedSubchannels(ImmutableSet.of());
}

/**
* Client-cancelled streams (e.g. non-winning hedged attempts) do not count as failures for
* failure percentage algorithm.
*/
@Test
public void failurePercentage_clientCancelled_notEjected() {
OutlierDetectionLoadBalancerConfig config = new OutlierDetectionLoadBalancerConfig.Builder()
.setMaxEjectionPercent(50)
.setFailurePercentageEjection(
new FailurePercentageEjection.Builder()
.setMinimumHosts(3)
.setRequestVolume(10).build())
.setChildConfig(newChildConfig(roundRobinLbProvider, null)).build();

loadBalancer.acceptResolvedAddresses(buildResolvedAddress(config, servers));

deliverSubchannelState(subchannel1, ConnectivityStateInfo.forNonError(READY));
deliverSubchannelState(subchannel2, ConnectivityStateInfo.forNonError(READY));
deliverSubchannelState(subchannel3, ConnectivityStateInfo.forNonError(READY));
deliverSubchannelState(subchannel4, ConnectivityStateInfo.forNonError(READY));
deliverSubchannelState(subchannel5, ConnectivityStateInfo.forNonError(READY));

verify(mockHelper, times(7)).updateBalancingState(stateCaptor.capture(),
pickerCaptor.capture());
SubchannelPicker picker = pickerCaptor.getAllValues()
.get(pickerCaptor.getAllValues().size() - 1);

for (int i = 0; i < 100; i++) {
PickResult pickResult = picker.pickSubchannel(mock(PickSubchannelArgs.class));
ClientStreamTracer clientStreamTracer = pickResult.getStreamTracerFactory()
.newClientStreamTracer(null, null);
Subchannel subchannel = (Subchannel) pickResult.getSubchannel().getInternalSubchannel();
if (subchannel == subchannel1) {
clientStreamTracer.cancelled(Status.CANCELLED);
clientStreamTracer.streamClosed(Status.CANCELLED);
} else {
clientStreamTracer.streamClosed(Status.OK);
}
}

forwardTime(config);

// subchannel1 was cancelled client-side and should not be ejected as an outlier.
assertEjectedSubchannels(ImmutableSet.of());
}

/**
* The success rate algorithm ejects the outlier.
*/
Expand Down
Loading