Skip to content

Commit d2fe520

Browse files
authored
fix: Unary Callables Deadline values respect the TotalTimeout in RetrySettings (#1603)
* chore: Add retry test * chore: Check the timeout for unary callables * chore: Fix tests * chore: Address code smell * chore: Add tests for DEADLINE_EXCEEDED * chore: Add tests for Server-side streaming * chore: Start the timeout after http request invocation * chore: Fix format issues * chore: Remove Instant calculation with System clock * chore: Add ITRetry test cases * chore: Fix the Retry showcase test * chore: Fix the Retry showcase test * chore: Convert duration to nanos * chore: Set the readTimeout min to 20s * chore: Add sucessful retry tests cases * chore: Add comments to timeout * chore: Update the connect timeout * chore: Refactor timeout logic * chore: Fix format issues * chore: Add logic for deadlineScheduler * chore: Fix format issues * chore: Update logic * chore: Update comment * chore: Update showcase test * chore: Fix format issues * chore: Fix logic * chore: Do not disconnect the connection * chore: Disconnect after end * chore: Resolve steam close error * chore: Fix disconnect logic * chore: Fix disconnect logic * chore: Update CI * chore: Fix native test * chore: Revert changes * chore: try with rpc timeout 100ms * chore: Fix format issues * chore: Re-run delivery loop with deadlineschedule priority * chore: Check for timeoutExceeded * chore: Do not send message is time exceeded * chore: Fix format issues * chore: Add timeout for tests * chore: Fix format issues * chore: Refactor trailer logic * chore: Refactor trailer logic * chore: Rename variables * chore: Increase the wait to 1s * chore: Fix format issues * chore: Set closed var as volatile * chore: Update logic for onClose * chore: Attempt with longer timeout * chore: Empty commit * chore: Fix format issues * chore: Trigger deliver loop instead of notifyListeners * chore: Remove variable * chore: Remove variable * chore: Fix close logic * chore: Revert graalvm ci * chore: Use 2s as delay * chore: Update to 5s delay * chore: Add comments for timeout method * chore: Use deliver loop in timeout * chore: Run matrix jobs sequentially * chore: Fix format issues * chore: Fix format issues * chore: Increase the wait to 10s * chore: Use 110ms delay * chore: Set delay to be 30s * chore: Fix format issues * chore: Log the onClose message * chore: Remove localRunnable * chore: Fix format issues * chore: Lower the retry amounts * chore: Lower the retry amounts * chore: Fix shouldRetry logic * chore: Log results of shouldRetry * chore: Ignore other retry tests * chore: Add more logging * chore: Fix shouldRetry logic * chore: Remove small optimization * chore: Temp ignore tests * chore: Temp ignore tests * chore: Add more logging * chore: revert back to checking for negative duration * chore: Revert ignored test * chore: Fix logging * chore: Log timeout * chore: Set min RPC timeout to be 1ms * chore: Update the retry algorithms * chore: Clean up the algoritms * chore: Uncomment out ITRetry tests * chore: Refactor the retryAlgorithms * chore: Add more comments * chore: Add in the parallel execution for ITs * chore: Add LRO showcase tests * chore: Fix format * chore: Remove deadline getters * chore: Remove sonar changes * chore: Fix algorithm test * chore: Log the flaky test * chore: Fix format * chore: Check for rpcTimeout being zero or negative * chore: Fix tests * chore: Fix format issues * chore: Remove unused code * chore: Update comment for RetryAlgorithm * chore: Fix format issues * chore: Use millis for timeout * chore: Await termination for clients * chore: Fix format issues * chore: Update LRO first call timeout value * chore: Update LRO test names * chore: Remove the parallel showcase tests * chore: Add showcase sequence tests for retries * chore: Add showcase sequence tests for retries * chore: Update retry test name * chore: Fix typos * chore: Fix server streaming callable test * chore: Clean up tests * chore: Remove retry tests * chore: Fix format issues * chore: Address PR comments * chore: Update java.time.Duration import * chore: Update values for LRO showcase test * chore: Update values for LRO showcase test * chore: Fix format issues * chore: Update variable name * chore: Convert shouldRetry logic to use milliseconds * chore: Remove jitter from tests * chore: Fix showcase test * chore: Log the attempt callable timeout * chore: Update LRO test case * chore: Add logging * chore: Use millis for timeout * chore: Address PR comments * chore: Update to use TestClientInitializer class * chore: Fix client initialize method names * chore: Add back public method * chore: Add back public methods
1 parent 6107ff3 commit d2fe520

File tree

17 files changed

+958
-68
lines changed

17 files changed

+958
-68
lines changed

.github/workflows/sonar.yaml

+1-2
Original file line numberDiff line numberDiff line change
@@ -79,5 +79,4 @@ jobs:
7979
-Dsonar.projectKey=googleapis_gapic-generator-java_unit_tests \
8080
-Dsonar.organization=googleapis \
8181
-Dsonar.host.url=https://sonarcloud.io \
82-
-Dsonar.projectName=java_showcase_unit_tests
83-
82+
-Dsonar.projectName=java_showcase_unit_tests

gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallOptions.java

+13
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import com.google.auth.Credentials;
3434
import com.google.auto.value.AutoValue;
3535
import com.google.protobuf.TypeRegistry;
36+
import java.time.Duration;
3637
import javax.annotation.Nullable;
3738
import org.threeten.bp.Instant;
3839

@@ -42,6 +43,9 @@
4243
public abstract class HttpJsonCallOptions {
4344
public static final HttpJsonCallOptions DEFAULT = newBuilder().build();
4445

46+
@Nullable
47+
public abstract Duration getTimeout();
48+
4549
@Nullable
4650
public abstract Instant getDeadline();
4751

@@ -69,6 +73,13 @@ public HttpJsonCallOptions merge(HttpJsonCallOptions inputOptions) {
6973
builder.setDeadline(newDeadline);
7074
}
7175

76+
if (inputOptions.getTimeout() != null) {
77+
Duration newTimeout = java.time.Duration.ofMillis(inputOptions.getTimeout().toMillis());
78+
if (newTimeout != null) {
79+
builder.setTimeout(newTimeout);
80+
}
81+
}
82+
7283
Credentials newCredentials = inputOptions.getCredentials();
7384
if (newCredentials != null) {
7485
builder.setCredentials(newCredentials);
@@ -84,6 +95,8 @@ public HttpJsonCallOptions merge(HttpJsonCallOptions inputOptions) {
8495

8596
@AutoValue.Builder
8697
public abstract static class Builder {
98+
public abstract Builder setTimeout(java.time.Duration value);
99+
87100
public abstract Builder setDeadline(Instant value);
88101

89102
public abstract Builder setCredentials(Credentials value);

gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonClientCallImpl.java

+57-17
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,19 @@
3333
import com.google.api.gax.httpjson.ApiMethodDescriptor.MethodType;
3434
import com.google.api.gax.httpjson.HttpRequestRunnable.ResultListener;
3535
import com.google.api.gax.httpjson.HttpRequestRunnable.RunnableResult;
36+
import com.google.api.gax.rpc.StatusCode;
3637
import com.google.common.base.Preconditions;
3738
import java.io.IOException;
3839
import java.io.InputStreamReader;
3940
import java.io.Reader;
4041
import java.nio.charset.StandardCharsets;
42+
import java.time.Duration;
4143
import java.util.ArrayDeque;
4244
import java.util.Queue;
4345
import java.util.concurrent.CancellationException;
4446
import java.util.concurrent.Executor;
47+
import java.util.concurrent.ScheduledExecutorService;
48+
import java.util.concurrent.TimeUnit;
4549
import javax.annotation.Nullable;
4650
import javax.annotation.concurrent.GuardedBy;
4751

@@ -88,6 +92,7 @@ final class HttpJsonClientCallImpl<RequestT, ResponseT>
8892
private final ApiMethodDescriptor<RequestT, ResponseT> methodDescriptor;
8993
private final HttpTransport httpTransport;
9094
private final Executor executor;
95+
private final ScheduledExecutorService deadlineCancellationExecutor;
9196

9297
//
9398
// Request-specific data (provided by client code) before we get a response.
@@ -114,19 +119,21 @@ final class HttpJsonClientCallImpl<RequestT, ResponseT>
114119
private ProtoMessageJsonStreamIterator responseStreamIterator;
115120

116121
@GuardedBy("lock")
117-
private boolean closed;
122+
private volatile boolean closed;
118123

119124
HttpJsonClientCallImpl(
120125
ApiMethodDescriptor<RequestT, ResponseT> methodDescriptor,
121126
String endpoint,
122127
HttpJsonCallOptions callOptions,
123128
HttpTransport httpTransport,
124-
Executor executor) {
129+
Executor executor,
130+
ScheduledExecutorService deadlineCancellationExecutor) {
125131
this.methodDescriptor = methodDescriptor;
126132
this.endpoint = endpoint;
127133
this.callOptions = callOptions;
128134
this.httpTransport = httpTransport;
129135
this.executor = executor;
136+
this.deadlineCancellationExecutor = deadlineCancellationExecutor;
130137
this.closed = false;
131138
}
132139

@@ -161,6 +168,38 @@ public void start(Listener<ResponseT> responseListener, HttpJsonMetadata request
161168
this.listener = responseListener;
162169
this.requestHeaders = requestHeaders;
163170
}
171+
172+
// Use the timeout duration value instead of calculating the future Instant
173+
// Only schedule the deadline if the RPC timeout has been set in the RetrySettings
174+
Duration timeout = callOptions.getTimeout();
175+
if (timeout != null) {
176+
// The future timeout value is guaranteed to not be a negative value as the
177+
// RetryAlgorithm will not retry
178+
long timeoutMs = timeout.toMillis();
179+
this.deadlineCancellationExecutor.schedule(this::timeout, timeoutMs, TimeUnit.MILLISECONDS);
180+
}
181+
}
182+
183+
// Notify the FutureListener that the there is a timeout exception from this RPC
184+
// call (DEADLINE_EXCEEDED). For retrying RPCs, this code is returned for every attempt
185+
// that exceeds the timeout. The RetryAlgorithm will check both the timing and code to
186+
// ensure another attempt is made.
187+
private void timeout() {
188+
// There is a race between the deadline scheduler and response being returned from
189+
// the server. The deadline scheduler has priority as it will clear out the pending
190+
// notifications queue and add the DEADLINE_EXCEEDED event once it is able to obtain
191+
// the lock.
192+
synchronized (lock) {
193+
close(
194+
StatusCode.Code.DEADLINE_EXCEEDED.getHttpStatusCode(),
195+
"Deadline exceeded",
196+
new HttpJsonStatusRuntimeException(
197+
StatusCode.Code.DEADLINE_EXCEEDED.getHttpStatusCode(), "Deadline exceeded", null),
198+
true);
199+
}
200+
201+
// trigger delivery loop if not already running
202+
deliver();
164203
}
165204

166205
@Override
@@ -260,9 +299,10 @@ private void deliver() {
260299
throw new InterruptedException("Message delivery has been interrupted");
261300
}
262301

263-
// All listeners must be called under delivery loop (but outside the lock) to ensure that no
264-
// two notifications come simultaneously from two different threads and that we do not go
265-
// indefinitely deep in the stack if delivery logic is called recursively via listeners.
302+
// All listeners must be called under delivery loop (but outside the lock) to ensure that
303+
// no two notifications come simultaneously from two different threads and that we do not
304+
// go indefinitely deep in the stack if delivery logic is called recursively via
305+
// listeners.
266306
notifyListeners();
267307

268308
// The synchronized block around message reading and cancellation notification processing
@@ -302,7 +342,7 @@ private void deliver() {
302342
inDelivery = false;
303343
break;
304344
} else {
305-
// We still have some stuff in notiticationTasksQueue so continue the loop, most
345+
// We still have some stuff in notificationTasksQueue so continue the loop, most
306346
// likely we will finally terminate on the next cycle.
307347
continue;
308348
}
@@ -319,8 +359,8 @@ private void deliver() {
319359
// can do in such an unlikely situation (otherwise we would stay forever in the delivery
320360
// loop).
321361
synchronized (lock) {
322-
// Close the call immediately marking it cancelled. If already closed close() will have no
323-
// effect.
362+
// Close the call immediately marking it cancelled. If already closed, close() will have
363+
// no effect.
324364
close(ex.getStatusCode(), ex.getMessage(), ex, true);
325365
}
326366
}
@@ -352,7 +392,7 @@ private boolean consumeMessageFromStream() throws IOException {
352392
boolean allMessagesConsumed;
353393
Reader responseReader;
354394
if (methodDescriptor.getType() == MethodType.SERVER_STREAMING) {
355-
// Lazily initialize responseStreamIterator in case if it is a server steraming response
395+
// Lazily initialize responseStreamIterator in case if it is a server streaming response
356396
if (responseStreamIterator == null) {
357397
responseStreamIterator =
358398
new ProtoMessageJsonStreamIterator(
@@ -384,7 +424,7 @@ private boolean consumeMessageFromStream() throws IOException {
384424

385425
@GuardedBy("lock")
386426
private void close(
387-
int statusCode, String message, Throwable cause, boolean terminateImmediatelly) {
427+
int statusCode, String message, Throwable cause, boolean terminateImmediately) {
388428
try {
389429
if (closed) {
390430
return;
@@ -399,12 +439,12 @@ private void close(
399439
requestRunnable = null;
400440
}
401441

402-
HttpJsonMetadata.Builder meatadaBuilder = HttpJsonMetadata.newBuilder();
442+
HttpJsonMetadata.Builder metadataBuilder = HttpJsonMetadata.newBuilder();
403443
if (runnableResult != null && runnableResult.getTrailers() != null) {
404-
meatadaBuilder = runnableResult.getTrailers().toBuilder();
444+
metadataBuilder = runnableResult.getTrailers().toBuilder();
405445
}
406-
meatadaBuilder.setException(cause);
407-
meatadaBuilder.setStatusMessage(message);
446+
metadataBuilder.setException(cause);
447+
metadataBuilder.setStatusMessage(message);
408448
if (responseStreamIterator != null) {
409449
responseStreamIterator.close();
410450
}
@@ -415,19 +455,19 @@ private void close(
415455
// onClose() suppresses all other pending notifications.
416456
// there should be no place in the code which inserts something in this queue before checking
417457
// the `closed` flag under the lock and refusing to insert anything if `closed == true`.
418-
if (terminateImmediatelly) {
458+
if (terminateImmediately) {
419459
// This usually means we are cancelling the call before processing the response in full.
420460
// It may happen if a user explicitly cancels the call or in response to an unexpected
421461
// exception either from server or a call listener execution.
422462
pendingNotifications.clear();
423463
}
424464

425465
pendingNotifications.offer(
426-
new OnCloseNotificationTask<>(listener, statusCode, meatadaBuilder.build()));
466+
new OnCloseNotificationTask<>(listener, statusCode, metadataBuilder.build()));
427467

428468
} catch (Throwable e) {
429469
// suppress stream closing exceptions in favor of the actual call closing cause. This method
430-
// should not throw, otherwise we may stuck in an infinite loop of exception processing.
470+
// should not throw, otherwise we may be stuck in an infinite loop of exception processing.
431471
}
432472
}
433473

gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonClientCalls.java

+18-6
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,7 @@
3434
import com.google.api.gax.rpc.ApiCallContext;
3535
import java.util.logging.Level;
3636
import java.util.logging.Logger;
37-
import javax.annotation.Nonnull;
38-
import org.threeten.bp.Instant;
37+
import org.threeten.bp.Duration;
3938

4039
/**
4140
* {@code HttpJsonClientCalls} creates a new {@code HttpJsonClientCAll} from the given call context.
@@ -50,12 +49,25 @@ public static <RequestT, ResponseT> HttpJsonClientCall<RequestT, ResponseT> newC
5049

5150
HttpJsonCallContext httpJsonContext = HttpJsonCallContext.createDefault().nullToSelf(context);
5251

53-
// Try to convert the timeout into a deadline and use it if it occurs before the actual deadline
52+
// Use the context's timeout instead of calculating a future deadline with the System clock.
53+
// The timeout value is calculated from TimedAttemptSettings which accounts for the
54+
// TotalTimeout value set in the RetrySettings.
5455
if (httpJsonContext.getTimeout() != null) {
55-
@Nonnull Instant newDeadline = Instant.now().plus(httpJsonContext.getTimeout());
5656
HttpJsonCallOptions callOptions = httpJsonContext.getCallOptions();
57-
if (callOptions.getDeadline() == null || newDeadline.isBefore(callOptions.getDeadline())) {
58-
callOptions = callOptions.toBuilder().setDeadline(newDeadline).build();
57+
// HttpJsonChannel expects the HttpJsonCallOptions and we store the timeout duration
58+
// inside the HttpJsonCallOptions
59+
// Note: There is manual conversion between threetenbp's Duration and java.util.Duration
60+
// This is temporary here as we plan to migrate to java.util.Duration
61+
if (callOptions.getTimeout() == null
62+
|| httpJsonContext
63+
.getTimeout()
64+
.compareTo(Duration.ofMillis(callOptions.getTimeout().toMillis()))
65+
< 0) {
66+
callOptions =
67+
callOptions
68+
.toBuilder()
69+
.setTimeout(java.time.Duration.ofMillis(httpJsonContext.getTimeout().toMillis()))
70+
.build();
5971
httpJsonContext = httpJsonContext.withCallOptions(callOptions);
6072
}
6173
}

gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpRequestRunnable.java

+33-17
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,11 @@
5252
import java.io.IOException;
5353
import java.io.InputStream;
5454
import java.nio.charset.StandardCharsets;
55+
import java.time.Duration;
5556
import java.util.List;
5657
import java.util.Map;
5758
import java.util.Map.Entry;
5859
import javax.annotation.Nullable;
59-
import org.threeten.bp.Duration;
60-
import org.threeten.bp.Instant;
6160

6261
/** A runnable object that creates and executes an HTTP request. */
6362
class HttpRequestRunnable<RequestT, ResponseT> implements Runnable {
@@ -100,24 +99,22 @@ void cancel() {
10099

101100
@Override
102101
public void run() {
103-
HttpResponse httpResponse = null;
104102
RunnableResult.Builder result = RunnableResult.builder();
105103
HttpJsonMetadata.Builder trailers = HttpJsonMetadata.newBuilder();
106-
HttpRequest httpRequest = null;
104+
HttpResponse httpResponse = null;
107105
try {
108106
// Check if already cancelled before even creating a request
109107
if (cancelled) {
110108
return;
111109
}
112-
httpRequest = createHttpRequest();
110+
HttpRequest httpRequest = createHttpRequest();
113111
// Check if already cancelled before sending the request;
114112
if (cancelled) {
115113
return;
116114
}
117-
118115
httpResponse = httpRequest.execute();
119116

120-
// Check if already cancelled before sending the request;
117+
// Check if already cancelled before trying to construct and read the response
121118
if (cancelled) {
122119
httpResponse.disconnect();
123120
return;
@@ -145,6 +142,9 @@ public void run() {
145142
}
146143
trailers.setException(e);
147144
} finally {
145+
// If cancelled, `close()` in HttpJsonClientCallImpl has already been invoked
146+
// and returned a DEADLINE_EXCEEDED error back so there is no need to set
147+
// a result back.
148148
if (!cancelled) {
149149
resultListener.setResult(result.setTrailers(trailers.build()).build());
150150
}
@@ -191,16 +191,6 @@ HttpRequest createHttpRequest() throws IOException {
191191

192192
HttpRequest httpRequest = buildRequest(requestFactory, url, jsonHttpContent);
193193

194-
Instant deadline = httpJsonCallOptions.getDeadline();
195-
if (deadline != null) {
196-
long readTimeout = Duration.between(Instant.now(), deadline).toMillis();
197-
if (httpRequest.getReadTimeout() > 0
198-
&& httpRequest.getReadTimeout() < readTimeout
199-
&& readTimeout < Integer.MAX_VALUE) {
200-
httpRequest.setReadTimeout((int) readTimeout);
201-
}
202-
}
203-
204194
for (Map.Entry<String, Object> entry : headers.getHeaders().entrySet()) {
205195
HttpHeadersUtils.setHeader(
206196
httpRequest.getHeaders(), entry.getKey(), (String) entry.getValue());
@@ -243,9 +233,35 @@ private HttpRequest buildRequest(
243233
HttpHeadersUtils.setHeader(
244234
httpRequest.getHeaders(), "X-HTTP-Method-Override", originalHttpMethod);
245235
}
236+
237+
Duration timeout = httpJsonCallOptions.getTimeout();
238+
if (timeout != null) {
239+
long timeoutMs = timeout.toMillis();
240+
241+
// Read timeout is the timeout between reading two data packets and not total timeout
242+
// HttpJsonClientCallsImpl implements a deadlineCancellationExecutor to cancel the
243+
// RPC when it exceeds the RPC timeout
244+
if (shouldUpdateTimeout(httpRequest.getReadTimeout(), timeoutMs)) {
245+
httpRequest.setReadTimeout((int) timeoutMs);
246+
}
247+
248+
// Connect timeout is the time allowed for establishing the connection.
249+
// This is updated to match the RPC timeout as we do not want a shorter
250+
// connect timeout to preemptively throw a ConnectExcepetion before
251+
// we've reached the RPC timeout
252+
if (shouldUpdateTimeout(httpRequest.getConnectTimeout(), timeoutMs)) {
253+
httpRequest.setConnectTimeout((int) timeoutMs);
254+
}
255+
}
246256
return httpRequest;
247257
}
248258

259+
private boolean shouldUpdateTimeout(int currentTimeoutMs, long newTimeoutMs) {
260+
return currentTimeoutMs > 0
261+
&& currentTimeoutMs < newTimeoutMs
262+
&& newTimeoutMs < Integer.MAX_VALUE;
263+
}
264+
249265
// This will be frequently executed, so avoiding using regexps if not necessary.
250266
private String normalizeEndpoint(String rawEndpoint) {
251267
String normalized = rawEndpoint;

0 commit comments

Comments
 (0)