Skip to content

Commit 5a12cd2

Browse files
fix: apply stream wait timeout (#2544)
* fix: apply stream wait timeout Use the streamWaitTimeout that has been set on the call context when polling from the gRPC stream. This prevents the stream from blocking forever if for some reason the stream is no longer delivering data, and also no error is propagated to the client. The default stream wait timeout that is set for all call contexts is 30 mins. This value can be overridden by configuring a custom call context for a specific query. Fixes #2494 * test: add a wait time to the mock server to ensure that a timeout occurs * chore: add clirr ignore * docs: add test + comment for zero timeout * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md --------- Co-authored-by: Owl Bot <gcf-owl-bot[bot]@users.noreply.github.com>
1 parent e456e7b commit 5a12cd2

File tree

8 files changed

+159
-33
lines changed

8 files changed

+159
-33
lines changed

google-cloud-spanner/clirr-ignored-differences.xml

+7
Original file line numberDiff line numberDiff line change
@@ -359,4 +359,11 @@
359359
<className>com/google/cloud/spanner/connection/Connection</className>
360360
<method>boolean isDelayTransactionStartUntilFirstWrite()</method>
361361
</difference>
362+
363+
<!-- (Internal change, use stream timeout) -->
364+
<difference>
365+
<differenceType>7012</differenceType>
366+
<className>com/google/cloud/spanner/spi/v1/SpannerRpc$StreamingCall</className>
367+
<method>com.google.api.gax.rpc.ApiCallContext getCallContext()</method>
368+
</difference>
362369
</differences>

google-cloud-spanner/src/main/java/com/google/cloud/spanner/AbstractResultSet.java

+29-5
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import com.google.api.client.util.BackOff;
2626
import com.google.api.client.util.ExponentialBackOff;
2727
import com.google.api.gax.retrying.RetrySettings;
28+
import com.google.api.gax.rpc.ApiCallContext;
2829
import com.google.cloud.ByteArray;
2930
import com.google.cloud.Date;
3031
import com.google.cloud.Timestamp;
@@ -74,6 +75,7 @@
7475
import java.util.stream.Collectors;
7576
import javax.annotation.Nonnull;
7677
import javax.annotation.Nullable;
78+
import org.threeten.bp.Duration;
7779

7880
/** Implementation of {@link ResultSet}. */
7981
abstract class AbstractResultSet<R> extends AbstractStructReader implements ResultSet {
@@ -944,6 +946,8 @@ static class GrpcStreamIterator extends AbstractIterator<PartialResultSet>
944946

945947
private SpannerRpc.StreamingCall call;
946948
private volatile boolean withBeginTransaction;
949+
private TimeUnit streamWaitTimeoutUnit;
950+
private long streamWaitTimeoutValue;
947951
private SpannerException error;
948952

949953
@VisibleForTesting
@@ -965,6 +969,22 @@ protected final SpannerRpc.ResultStreamConsumer consumer() {
965969
public void setCall(SpannerRpc.StreamingCall call, boolean withBeginTransaction) {
966970
this.call = call;
967971
this.withBeginTransaction = withBeginTransaction;
972+
ApiCallContext callContext = call.getCallContext();
973+
Duration streamWaitTimeout = callContext == null ? null : callContext.getStreamWaitTimeout();
974+
if (streamWaitTimeout != null) {
975+
// Determine the timeout unit to use. This reduces the precision to seconds if the timeout
976+
// value is more than 1 second, which is lower than the precision that would normally be
977+
// used by the stream watchdog (which uses a precision of 10 seconds by default).
978+
if (streamWaitTimeout.getSeconds() > 0L) {
979+
streamWaitTimeoutValue = streamWaitTimeout.getSeconds();
980+
streamWaitTimeoutUnit = TimeUnit.SECONDS;
981+
} else if (streamWaitTimeout.getNano() > 0) {
982+
streamWaitTimeoutValue = streamWaitTimeout.getNano();
983+
streamWaitTimeoutUnit = TimeUnit.NANOSECONDS;
984+
}
985+
// Note that if the stream-wait-timeout is zero, we won't set a timeout at all.
986+
// That is consistent with ApiCallContext#withStreamWaitTimeout(Duration.ZERO).
987+
}
968988
}
969989

970990
@Override
@@ -983,11 +1003,15 @@ public boolean isWithBeginTransaction() {
9831003
protected final PartialResultSet computeNext() {
9841004
PartialResultSet next;
9851005
try {
986-
// TODO: Ideally honor io.grpc.Context while blocking here. In practice,
987-
// cancellation/deadline results in an error being delivered to "stream", which
988-
// should mean that we do not block significantly longer afterwards, but it would
989-
// be more robust to use poll() with a timeout.
990-
next = stream.take();
1006+
if (streamWaitTimeoutUnit != null) {
1007+
next = stream.poll(streamWaitTimeoutValue, streamWaitTimeoutUnit);
1008+
if (next == null) {
1009+
throw SpannerExceptionFactory.newSpannerException(
1010+
ErrorCode.DEADLINE_EXCEEDED, "stream wait timeout");
1011+
}
1012+
} else {
1013+
next = stream.take();
1014+
}
9911015
} catch (InterruptedException e) {
9921016
// Treat interrupt as a request to cancel the read.
9931017
throw SpannerExceptionFactory.propagateInterrupt(e);

google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/GapicSpannerRpc.java

+28-28
Original file line numberDiff line numberDiff line change
@@ -1596,20 +1596,7 @@ public StreamingCall read(
15961596
options, request.getSession(), request, SpannerGrpc.getReadMethod(), routeToLeader);
15971597
SpannerResponseObserver responseObserver = new SpannerResponseObserver(consumer);
15981598
spannerStub.streamingReadCallable().call(request, responseObserver, context);
1599-
final StreamController controller = responseObserver.getController();
1600-
return new StreamingCall() {
1601-
@Override
1602-
public void request(int numMessage) {
1603-
controller.request(numMessage);
1604-
}
1605-
1606-
// TODO(hzyi): streamController currently does not support cancel with message. Add
1607-
// this in gax and update this method later
1608-
@Override
1609-
public void cancel(String message) {
1610-
controller.cancel();
1611-
}
1612-
};
1599+
return new GrpcStreamingCall(context, responseObserver.getController());
16131600
}
16141601

16151602
@Override
@@ -1673,22 +1660,10 @@ public StreamingCall executeQuery(
16731660
request,
16741661
SpannerGrpc.getExecuteStreamingSqlMethod(),
16751662
routeToLeader);
1663+
16761664
SpannerResponseObserver responseObserver = new SpannerResponseObserver(consumer);
16771665
spannerStub.executeStreamingSqlCallable().call(request, responseObserver, context);
1678-
final StreamController controller = responseObserver.getController();
1679-
return new StreamingCall() {
1680-
@Override
1681-
public void request(int numMessage) {
1682-
controller.request(numMessage);
1683-
}
1684-
1685-
// TODO(hzyi): streamController currently does not support cancel with message. Add
1686-
// this in gax and update this method later
1687-
@Override
1688-
public void cancel(String message) {
1689-
controller.cancel();
1690-
}
1691-
};
1666+
return new GrpcStreamingCall(context, responseObserver.getController());
16921667
}
16931668

16941669
@Override
@@ -1957,6 +1932,31 @@ public boolean isClosed() {
19571932
return rpcIsClosed;
19581933
}
19591934

1935+
private static final class GrpcStreamingCall implements StreamingCall {
1936+
private final ApiCallContext callContext;
1937+
private final StreamController controller;
1938+
1939+
GrpcStreamingCall(ApiCallContext callContext, StreamController controller) {
1940+
this.callContext = callContext;
1941+
this.controller = controller;
1942+
}
1943+
1944+
@Override
1945+
public ApiCallContext getCallContext() {
1946+
return callContext;
1947+
}
1948+
1949+
@Override
1950+
public void request(int numMessages) {
1951+
controller.request(numMessages);
1952+
}
1953+
1954+
@Override
1955+
public void cancel(@Nullable String message) {
1956+
controller.cancel();
1957+
}
1958+
}
1959+
19601960
/**
19611961
* A {@code ResponseObserver} that exposes the {@code StreamController} and delegates callbacks to
19621962
* the {@link ResultStreamConsumer}.

google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/SpannerRpc.java

+4
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import com.google.api.core.InternalApi;
2121
import com.google.api.gax.longrunning.OperationFuture;
2222
import com.google.api.gax.retrying.RetrySettings;
23+
import com.google.api.gax.rpc.ApiCallContext;
2324
import com.google.api.gax.rpc.ServerStream;
2425
import com.google.cloud.ServiceRpc;
2526
import com.google.cloud.spanner.BackupId;
@@ -150,6 +151,9 @@ interface ResultStreamConsumer {
150151
/** Handle for cancellation of a streaming read or query call. */
151152
interface StreamingCall {
152153

154+
/** Returns the {@link ApiCallContext} that is used for this streaming call. */
155+
ApiCallContext getCallContext();
156+
153157
/**
154158
* Requests more messages from the stream. We disable the auto flow control mechanism in grpc,
155159
* so we need to request messages ourself. This gives us more control over how much buffer we

google-cloud-spanner/src/test/java/com/google/cloud/spanner/DatabaseClientImplTest.java

+60
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
import com.google.api.core.ApiFutures;
3939
import com.google.api.gax.grpc.testing.LocalChannelProvider;
4040
import com.google.api.gax.retrying.RetrySettings;
41+
import com.google.api.gax.rpc.ApiCallContext;
4142
import com.google.cloud.ByteArray;
4243
import com.google.cloud.NoCredentials;
4344
import com.google.cloud.Timestamp;
@@ -51,6 +52,7 @@
5152
import com.google.cloud.spanner.ReadContext.QueryAnalyzeMode;
5253
import com.google.cloud.spanner.SessionPool.PooledSessionFuture;
5354
import com.google.cloud.spanner.SpannerException.ResourceNotFoundException;
55+
import com.google.cloud.spanner.SpannerOptions.CallContextConfigurator;
5456
import com.google.cloud.spanner.SpannerOptions.SpannerCallContextTimeoutConfigurator;
5557
import com.google.cloud.spanner.Type.Code;
5658
import com.google.cloud.spanner.connection.RandomResultSetGenerator;
@@ -77,6 +79,7 @@
7779
import com.google.spanner.v1.TypeAnnotationCode;
7880
import com.google.spanner.v1.TypeCode;
7981
import io.grpc.Context;
82+
import io.grpc.MethodDescriptor;
8083
import io.grpc.Server;
8184
import io.grpc.Status;
8285
import io.grpc.StatusRuntimeException;
@@ -2963,6 +2966,63 @@ public void testStatementWithBytesArrayParameter() {
29632966
}
29642967
}
29652968

2969+
@Test
2970+
public void testStreamWaitTimeout() {
2971+
DatabaseClient client =
2972+
spanner.getDatabaseClient(DatabaseId.of(TEST_PROJECT, TEST_INSTANCE, TEST_DATABASE));
2973+
// Add a wait time to the mock server. Note that the test won't actually wait 100ms, as it uses
2974+
// a 1ns time out.
2975+
mockSpanner.setExecuteStreamingSqlExecutionTime(
2976+
SimulatedExecutionTime.ofMinimumAndRandomTime(100, 0));
2977+
// Create a custom call configuration that uses a 1 nanosecond stream timeout value. This will
2978+
// always time out, as a call to the mock server will always take more than 1 nanosecond.
2979+
CallContextConfigurator configurator =
2980+
new CallContextConfigurator() {
2981+
@Override
2982+
public <ReqT, RespT> ApiCallContext configure(
2983+
ApiCallContext context, ReqT request, MethodDescriptor<ReqT, RespT> method) {
2984+
return context.withStreamWaitTimeout(Duration.ofNanos(1L));
2985+
}
2986+
};
2987+
Context context =
2988+
Context.current().withValue(SpannerOptions.CALL_CONTEXT_CONFIGURATOR_KEY, configurator);
2989+
context.run(
2990+
() -> {
2991+
try (ResultSet resultSet = client.singleUse().executeQuery(SELECT1)) {
2992+
SpannerException exception = assertThrows(SpannerException.class, resultSet::next);
2993+
assertEquals(ErrorCode.DEADLINE_EXCEEDED, exception.getErrorCode());
2994+
assertTrue(
2995+
exception.getMessage(), exception.getMessage().contains("stream wait timeout"));
2996+
}
2997+
});
2998+
}
2999+
3000+
@Test
3001+
public void testZeroStreamWaitTimeout() {
3002+
DatabaseClient client =
3003+
spanner.getDatabaseClient(DatabaseId.of(TEST_PROJECT, TEST_INSTANCE, TEST_DATABASE));
3004+
// Create a custom call configuration that sets the stream timeout to zero.
3005+
// This should disable the timeout.
3006+
CallContextConfigurator configurator =
3007+
new CallContextConfigurator() {
3008+
@Override
3009+
public <ReqT, RespT> ApiCallContext configure(
3010+
ApiCallContext context, ReqT request, MethodDescriptor<ReqT, RespT> method) {
3011+
return context.withStreamWaitTimeout(Duration.ZERO);
3012+
}
3013+
};
3014+
Context context =
3015+
Context.current().withValue(SpannerOptions.CALL_CONTEXT_CONFIGURATOR_KEY, configurator);
3016+
context.run(
3017+
() -> {
3018+
try (ResultSet resultSet = client.singleUse().executeQuery(SELECT1)) {
3019+
// A zero timeout should not cause a timeout, and instead be ignored.
3020+
assertTrue(resultSet.next());
3021+
assertFalse(resultSet.next());
3022+
}
3023+
});
3024+
}
3025+
29663026
static void assertAsString(String expected, ResultSet resultSet, int col) {
29673027
assertEquals(expected, resultSet.getValue(col).getAsString());
29683028
assertEquals(ImmutableList.of(expected), resultSet.getValue(col).getAsStringList());

google-cloud-spanner/src/test/java/com/google/cloud/spanner/GrpcResultSetTest.java

+17
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
import static org.junit.Assert.assertThrows;
2323
import static org.junit.Assert.assertTrue;
2424

25+
import com.google.api.gax.grpc.GrpcCallContext;
26+
import com.google.api.gax.rpc.ApiCallContext;
2527
import com.google.cloud.ByteArray;
2628
import com.google.cloud.Date;
2729
import com.google.cloud.Timestamp;
@@ -50,6 +52,7 @@
5052
import org.junit.Test;
5153
import org.junit.runner.RunWith;
5254
import org.junit.runners.JUnit4;
55+
import org.threeten.bp.Duration;
5356

5457
/** Unit tests for {@link com.google.cloud.spanner.AbstractResultSet.GrpcResultSet}. */
5558
@RunWith(JUnit4.class)
@@ -58,6 +61,7 @@ public class GrpcResultSetTest {
5861
private AbstractResultSet.GrpcResultSet resultSet;
5962
private SpannerRpc.ResultStreamConsumer consumer;
6063
private AbstractResultSet.GrpcStreamIterator stream;
64+
private final Duration streamWaitTimeout = Duration.ofNanos(1L);
6165

6266
private static class NoOpListener implements AbstractResultSet.Listener {
6367
@Override
@@ -78,6 +82,11 @@ public void setUp() {
7882
stream = new AbstractResultSet.GrpcStreamIterator(10);
7983
stream.setCall(
8084
new SpannerRpc.StreamingCall() {
85+
@Override
86+
public ApiCallContext getCallContext() {
87+
return GrpcCallContext.createDefault().withStreamWaitTimeout(streamWaitTimeout);
88+
}
89+
8190
@Override
8291
public void cancel(@Nullable String message) {}
8392

@@ -93,6 +102,14 @@ public AbstractResultSet.GrpcResultSet resultSetWithMode(QueryMode queryMode) {
93102
return new AbstractResultSet.GrpcResultSet(stream, new NoOpListener());
94103
}
95104

105+
@Test
106+
public void testStreamTimeout() {
107+
// We don't add any results to the stream. That means that it will time out after 1ns.
108+
SpannerException exception = assertThrows(SpannerException.class, resultSet::next);
109+
assertEquals(ErrorCode.DEADLINE_EXCEEDED, exception.getErrorCode());
110+
assertTrue(exception.getMessage(), exception.getMessage().contains("stream wait timeout"));
111+
}
112+
96113
@Test
97114
public void metadata() {
98115
Type rowType = Type.struct(Type.StructField.of("f", Type.string()));

google-cloud-spanner/src/test/java/com/google/cloud/spanner/ReadFormatTestRunner.java

+7
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818

1919
import static com.google.common.truth.Truth.assertThat;
2020

21+
import com.google.api.gax.grpc.GrpcCallContext;
22+
import com.google.api.gax.rpc.ApiCallContext;
2123
import com.google.cloud.ByteArray;
2224
import com.google.cloud.spanner.spi.v1.SpannerRpc;
2325
import com.google.common.io.Resources;
@@ -115,6 +117,11 @@ private void run() throws Exception {
115117
stream = new AbstractResultSet.GrpcStreamIterator(10);
116118
stream.setCall(
117119
new SpannerRpc.StreamingCall() {
120+
@Override
121+
public ApiCallContext getCallContext() {
122+
return GrpcCallContext.createDefault();
123+
}
124+
118125
@Override
119126
public void cancel(@Nullable String message) {}
120127

google-cloud-spanner/src/test/java/com/google/cloud/spanner/SessionImplTest.java

+7
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@
2727

2828
import com.google.api.core.ApiFutures;
2929
import com.google.api.core.NanoClock;
30+
import com.google.api.gax.grpc.GrpcCallContext;
3031
import com.google.api.gax.retrying.RetrySettings;
32+
import com.google.api.gax.rpc.ApiCallContext;
3133
import com.google.cloud.Timestamp;
3234
import com.google.cloud.grpc.GrpcTransportOptions;
3335
import com.google.cloud.grpc.GrpcTransportOptions.ExecutorFactory;
@@ -407,6 +409,11 @@ public void singleUseReadOnlyTransactionReturnsEmptyTransactionMetadata() {
407409
}
408410

409411
private static class NoOpStreamingCall implements SpannerRpc.StreamingCall {
412+
@Override
413+
public ApiCallContext getCallContext() {
414+
return GrpcCallContext.createDefault();
415+
}
416+
410417
@Override
411418
public void cancel(@Nullable String message) {}
412419

0 commit comments

Comments
 (0)