Skip to content

Commit 1dc0f6e

Browse files
committed
fix(spanner): cancel the Commit RPC on commit timeout or interrupt
`TransactionContextImpl.commit()` cancels the in-flight Commit RPC when the calling thread is interrupted or the bounded `get()` times out: } catch (InterruptedException | TimeoutException e) { if (commitFuture != null) { commitFuture.cancel(true); } But the volatile field `commitFuture` was never assigned. The only assignment done in `CommitRunnable.run()` was to a local variable that shadowed it. So the field stayed `null` and `commitFuture.cancel(true)` was dead code: on interrupt/timeout the client abandoned the call while the Commit RPC could still complete server-side. Fix that by publishing the RPC future to the field, and name the field `inFlightCommitFuture` for its cancellation role so it no longer collides with the local. The `null` check still covers the case where commit times out before the RPC is even sent. Signed-off-by: Fredrik Fornwall <fredrik@fornwall.net>
1 parent 2f915ab commit 1dc0f6e

2 files changed

Lines changed: 36 additions & 3 deletions

File tree

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -365,8 +365,8 @@ void commit() {
365365
rpc.getCommitRetrySettings().getTotalTimeout().getSeconds() + 5,
366366
TimeUnit.SECONDS);
367367
} catch (InterruptedException | TimeoutException e) {
368-
if (commitFuture != null) {
369-
commitFuture.cancel(true);
368+
if (inFlightCommitFuture != null) {
369+
inFlightCommitFuture.cancel(true);
370370
}
371371
if (e instanceof InterruptedException) {
372372
throw SpannerExceptionFactory.propagateInterrupt((InterruptedException) e);
@@ -378,7 +378,7 @@ void commit() {
378378
}
379379
}
380380

381-
volatile ApiFuture<CommitResponse> commitFuture;
381+
volatile ApiFuture<com.google.spanner.v1.CommitResponse> inFlightCommitFuture;
382382

383383
ApiFuture<CommitResponse> commitAsync() {
384384
close();
@@ -491,6 +491,7 @@ public void run() {
491491
try (IScope ignore = tracer.withSpan(opSpan)) {
492492
commitFuture = rpc.commitAsync(commitRequest, getTransactionChannelHint());
493493
}
494+
inFlightCommitFuture = commitFuture;
494495
session.markUsed(clock.instant());
495496
commitFuture.addListener(
496497
() -> {

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import static org.junit.Assert.assertArrayEquals;
2121
import static org.junit.Assert.assertEquals;
2222
import static org.junit.Assert.assertThrows;
23+
import static org.junit.Assert.assertTrue;
2324
import static org.mockito.ArgumentMatchers.any;
2425
import static org.mockito.ArgumentMatchers.eq;
2526
import static org.mockito.Mockito.doThrow;
@@ -30,6 +31,7 @@
3031
import static org.mockito.Mockito.when;
3132

3233
import com.google.api.core.ApiFutures;
34+
import com.google.api.core.SettableApiFuture;
3335
import com.google.cloud.grpc.GrpcTransportOptions;
3436
import com.google.cloud.grpc.GrpcTransportOptions.ExecutorFactory;
3537
import com.google.cloud.spanner.ErrorHandler.DefaultErrorHandler;
@@ -195,6 +197,36 @@ public void testCommitWithClientContext() {
195197
assertEquals(clientContext, capturedOptions.clientContext());
196198
}
197199

200+
@Test
201+
public void commitCancelsInFlightRpcWhenCallingThreadInterrupted() {
202+
when(session.getName()).thenReturn("projects/p/instances/i/databases/d/sessions/s");
203+
TransactionContextImpl transaction =
204+
TransactionContextImpl.newBuilder()
205+
.setSession(session)
206+
.setTransactionId(ByteString.copyFromUtf8("test-txn"))
207+
.setOptions(Options.fromTransactionOptions())
208+
.setRpc(rpc)
209+
.setTracer(tracer)
210+
.setSpan(span)
211+
.build();
212+
SettableApiFuture<CommitResponse> inFlightCommit = SettableApiFuture.create();
213+
when(rpc.commitAsync(Mockito.any(CommitRequest.class), Mockito.anyMap()))
214+
.thenAnswer(
215+
invocation -> {
216+
Thread.currentThread().interrupt();
217+
return inFlightCommit;
218+
});
219+
220+
try {
221+
SpannerException e = assertThrows(SpannerException.class, transaction::commit);
222+
assertEquals(ErrorCode.CANCELLED, e.getErrorCode());
223+
assertTrue("in-flight Commit RPC was not cancelled", inFlightCommit.isCancelled());
224+
} finally {
225+
// Clear the interrupt flag so it cannot leak into other tests.
226+
Thread.interrupted();
227+
}
228+
}
229+
198230
@SuppressWarnings("unchecked")
199231
@Test
200232
public void usesPreparedTransaction() {

0 commit comments

Comments
 (0)