Skip to content

Commit 5e26596

Browse files
authored
fix: recalculate remaining statement timeout after retry (#4053)
If a connection has a statement timeout and a statement is executed in a read/write transaction, the statement timeout would be reset to the original value during each retry, instead of being reduced after each attempt. This could cause multiple transaction retries to make the execution of a single statement take much longer than the configured timeout value.
1 parent 26385f9 commit 5e26596

File tree

3 files changed

+38
-2
lines changed

3 files changed

+38
-2
lines changed

google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/AbstractBaseUnitOfWork.java

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,17 +39,18 @@
3939
import com.google.cloud.spanner.Struct;
4040
import com.google.cloud.spanner.Type.StructField;
4141
import com.google.cloud.spanner.connection.AbstractStatementParser.ParsedStatement;
42-
import com.google.cloud.spanner.connection.ReadWriteTransaction.Builder;
4342
import com.google.cloud.spanner.connection.StatementExecutor.StatementTimeout;
4443
import com.google.common.base.Preconditions;
4544
import com.google.common.collect.ImmutableList;
4645
import com.google.common.util.concurrent.MoreExecutors;
4746
import io.grpc.Context;
47+
import io.grpc.Deadline;
4848
import io.grpc.MethodDescriptor;
4949
import io.grpc.Status;
5050
import io.opentelemetry.api.common.AttributeKey;
5151
import io.opentelemetry.api.trace.Span;
5252
import io.opentelemetry.context.Scope;
53+
import java.time.Duration;
5354
import java.util.Collection;
5455
import java.util.Collections;
5556
import java.util.HashSet;
@@ -358,6 +359,9 @@ <T> ApiFuture<T> executeStatementAsync(
358359
}
359360
Context context = Context.current();
360361
if (statementTimeout.hasTimeout() && !applyStatementTimeoutToMethods.isEmpty()) {
362+
Deadline deadline =
363+
Deadline.after(
364+
statementTimeout.getTimeoutValue(TimeUnit.NANOSECONDS), TimeUnit.NANOSECONDS);
361365
context =
362366
context.withValue(
363367
SpannerOptions.CALL_CONTEXT_CONFIGURATOR_KEY,
@@ -367,8 +371,14 @@ public <ReqT, RespT> ApiCallContext configure(
367371
ApiCallContext context, ReqT request, MethodDescriptor<ReqT, RespT> method) {
368372
if (statementTimeout.hasTimeout()
369373
&& applyStatementTimeoutToMethods.contains(method)) {
374+
// Calculate the remaining timeout. This method could be called multiple times
375+
// if the transaction is retried.
376+
long remainingTimeout = deadline.timeRemaining(TimeUnit.NANOSECONDS);
377+
if (remainingTimeout <= 0) {
378+
remainingTimeout = 1;
379+
}
370380
return GrpcCallContext.createDefault()
371-
.withTimeoutDuration(statementTimeout.asDuration());
381+
.withTimeoutDuration(Duration.ofNanos(remainingTimeout));
372382
}
373383
return null;
374384
}

google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/AbortedTest.java

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import com.google.cloud.Timestamp;
2828
import com.google.cloud.spanner.AbortedDueToConcurrentModificationException;
2929
import com.google.cloud.spanner.ErrorCode;
30+
import com.google.cloud.spanner.MockSpannerServiceImpl.SimulatedExecutionTime;
3031
import com.google.cloud.spanner.MockSpannerServiceImpl.StatementResult;
3132
import com.google.cloud.spanner.ReadContext.QueryAnalyzeMode;
3233
import com.google.cloud.spanner.ResultSet;
@@ -52,6 +53,7 @@
5253
import io.grpc.StatusRuntimeException;
5354
import java.util.Collections;
5455
import java.util.List;
56+
import java.util.concurrent.TimeUnit;
5557
import java.util.stream.Collectors;
5658
import java.util.stream.LongStream;
5759
import org.junit.Test;
@@ -583,6 +585,29 @@ public void testAbortedWithBitReversedSequence() {
583585
}
584586
}
585587

588+
@Test
589+
public void testTimeoutWithRetries() {
590+
// Verifies that even though a single execution of a statement does not exceed the deadline,
591+
// repeated retries of the statement does cause the deadline to be exceeded.
592+
try (ITConnection connection = createConnection()) {
593+
for (boolean autoCommit : new boolean[] {true, false}) {
594+
connection.setAutocommit(autoCommit);
595+
mockSpanner.setAbortProbability(1.0);
596+
mockSpanner.setExecuteSqlExecutionTime(SimulatedExecutionTime.ofMinimumAndRandomTime(1, 0));
597+
598+
connection.setStatementTimeout(10, TimeUnit.MILLISECONDS);
599+
SpannerException exception =
600+
assertThrows(SpannerException.class, () -> connection.execute(INSERT_STATEMENT));
601+
assertEquals(ErrorCode.DEADLINE_EXCEEDED, exception.getErrorCode());
602+
if (!autoCommit) {
603+
connection.rollback();
604+
}
605+
}
606+
} finally {
607+
mockSpanner.setAbortProbability(0.0);
608+
}
609+
}
610+
586611
static com.google.spanner.v1.ResultSet createBitReversedSequenceResultSet(
587612
long startValue, long endValue) {
588613
return com.google.spanner.v1.ResultSet.newBuilder()

google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/AbstractMockServerTest.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ public static void stopServer() {
252252
@Before
253253
public void setupResults() {
254254
mockSpanner.clearRequests();
255+
mockSpanner.removeAllExecutionTimes();
255256
mockDatabaseAdmin.getRequests().clear();
256257
mockInstanceAdmin.getRequests().clear();
257258
}

0 commit comments

Comments
 (0)