Skip to content

Commit f30fbf5

Browse files
committed
fix(spanner): cancel the in-flight Commit RPC when the commit is abandoned
Signed-off-by: Fredrik Fornwall <fredrik@fornwall.net>
1 parent 2f915ab commit f30fbf5

2 files changed

Lines changed: 187 additions & 4 deletions

File tree

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

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,14 @@ public void removeListener(Runnable listener) {
187187
@GuardedBy("precommitTokenLock")
188188
private MultiplexedSessionPrecommitToken latestPrecommitToken;
189189

190+
private final Object commitCancellationLock = new Object();
191+
192+
@GuardedBy("commitCancellationLock")
193+
private boolean commitCancelled;
194+
195+
@GuardedBy("commitCancellationLock")
196+
private ApiFuture<com.google.spanner.v1.CommitResponse> inFlightCommitFuture;
197+
190198
@GuardedBy("lock")
191199
private volatile SettableApiFuture<Void> finishedAsyncOperations = SettableApiFuture.create();
192200

@@ -365,9 +373,7 @@ void commit() {
365373
rpc.getCommitRetrySettings().getTotalTimeout().getSeconds() + 5,
366374
TimeUnit.SECONDS);
367375
} catch (InterruptedException | TimeoutException e) {
368-
if (commitFuture != null) {
369-
commitFuture.cancel(true);
370-
}
376+
cancelInFlightCommit();
371377
if (e instanceof InterruptedException) {
372378
throw SpannerExceptionFactory.propagateInterrupt((InterruptedException) e);
373379
} else {
@@ -378,7 +384,35 @@ void commit() {
378384
}
379385
}
380386

381-
volatile ApiFuture<CommitResponse> commitFuture;
387+
private void cancelInFlightCommit() {
388+
ApiFuture<com.google.spanner.v1.CommitResponse> commitFuture;
389+
synchronized (commitCancellationLock) {
390+
// Record the cancellation even if the RPC has not been sent yet, so that CommitRunnable
391+
// cancels it as soon as it is sent instead of leaving it running server-side.
392+
commitCancelled = true;
393+
commitFuture = inFlightCommitFuture;
394+
}
395+
// Cancel outside the lock, as this runs the listeners of the future on the current thread.
396+
if (commitFuture != null) {
397+
commitFuture.cancel(true);
398+
}
399+
}
400+
401+
private void publishOrCancelInFlightCommit(
402+
ApiFuture<com.google.spanner.v1.CommitResponse> commitFuture) {
403+
boolean cancelled;
404+
synchronized (commitCancellationLock) {
405+
// The commit can already have been abandoned while this RPC was being sent, in which case
406+
// cancelInFlightCommit() did not see this future and it is cancelled here instead.
407+
cancelled = commitCancelled;
408+
if (!cancelled) {
409+
inFlightCommitFuture = commitFuture;
410+
}
411+
}
412+
if (cancelled) {
413+
commitFuture.cancel(true);
414+
}
415+
}
382416

383417
ApiFuture<CommitResponse> commitAsync() {
384418
close();
@@ -430,6 +464,16 @@ ApiFuture<CommitResponse> commitAsync() {
430464
new CommitRunnable(
431465
res, finishOps, builder, /* retryAttemptDueToCommitProtocolExtension= */ false),
432466
MoreExecutors.directExecutor());
467+
// Cancelling the future that is returned to the caller must also cancel the Commit RPC, in
468+
// the same way as the timeout/interrupt path in commit() does. This listener also runs when
469+
// the commit finishes normally, hence the isCancelled() check.
470+
res.addListener(
471+
() -> {
472+
if (res.isCancelled()) {
473+
cancelInFlightCommit();
474+
}
475+
},
476+
MoreExecutors.directExecutor());
433477
return res;
434478
}
435479

@@ -491,6 +535,7 @@ public void run() {
491535
try (IScope ignore = tracer.withSpan(opSpan)) {
492536
commitFuture = rpc.commitAsync(commitRequest, getTransactionChannelHint());
493537
}
538+
publishOrCancelInFlightCommit(commitFuture);
494539
session.markUsed(clock.instant());
495540
commitFuture.addListener(
496541
() -> {

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

Lines changed: 138 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;
@@ -29,7 +30,9 @@
2930
import static org.mockito.Mockito.verify;
3031
import static org.mockito.Mockito.when;
3132

33+
import com.google.api.core.ApiFuture;
3234
import com.google.api.core.ApiFutures;
35+
import com.google.api.core.SettableApiFuture;
3336
import com.google.cloud.grpc.GrpcTransportOptions;
3437
import com.google.cloud.grpc.GrpcTransportOptions.ExecutorFactory;
3538
import com.google.cloud.spanner.ErrorHandler.DefaultErrorHandler;
@@ -195,6 +198,141 @@ public void testCommitWithClientContext() {
195198
assertEquals(clientContext, capturedOptions.clientContext());
196199
}
197200

201+
@Test
202+
public void commitCancelsInFlightRpcWhenCallingThreadInterrupted() {
203+
when(session.getName()).thenReturn("projects/p/instances/i/databases/d/sessions/s");
204+
TransactionContextImpl transaction =
205+
TransactionContextImpl.newBuilder()
206+
.setSession(session)
207+
.setTransactionId(ByteString.copyFromUtf8("test-txn"))
208+
.setOptions(Options.fromTransactionOptions())
209+
.setRpc(rpc)
210+
.setTracer(tracer)
211+
.setSpan(span)
212+
.build();
213+
SettableApiFuture<CommitResponse> inFlightCommit = SettableApiFuture.create();
214+
when(rpc.commitAsync(Mockito.any(CommitRequest.class), Mockito.anyMap()))
215+
.thenAnswer(
216+
invocation -> {
217+
Thread.currentThread().interrupt();
218+
return inFlightCommit;
219+
});
220+
221+
try {
222+
SpannerException e = assertThrows(SpannerException.class, transaction::commit);
223+
assertEquals(ErrorCode.CANCELLED, e.getErrorCode());
224+
assertTrue("in-flight Commit RPC was not cancelled", inFlightCommit.isCancelled());
225+
} finally {
226+
// Clear the interrupt flag so it cannot leak into other tests.
227+
Thread.interrupted();
228+
}
229+
}
230+
231+
@Test
232+
public void commitAsyncCancelsInFlightRpcWhenReturnedFutureIsCancelled() {
233+
when(session.getName()).thenReturn("projects/p/instances/i/databases/d/sessions/s");
234+
TransactionContextImpl transaction =
235+
TransactionContextImpl.newBuilder()
236+
.setSession(session)
237+
.setTransactionId(ByteString.copyFromUtf8("test-txn"))
238+
.setOptions(Options.fromTransactionOptions())
239+
.setRpc(rpc)
240+
.setTracer(tracer)
241+
.setSpan(span)
242+
.build();
243+
SettableApiFuture<CommitResponse> inFlightCommit = SettableApiFuture.create();
244+
when(rpc.commitAsync(Mockito.any(CommitRequest.class), Mockito.anyMap()))
245+
.thenReturn(inFlightCommit);
246+
247+
ApiFuture<com.google.cloud.spanner.CommitResponse> commitFuture = transaction.commitAsync();
248+
assertTrue(commitFuture.cancel(true));
249+
250+
assertTrue("in-flight Commit RPC was not cancelled", inFlightCommit.isCancelled());
251+
}
252+
253+
@Test
254+
public void commitAsyncCancelsCommitRpcThatIsSentAfterCancellation() {
255+
SettableApiFuture<Transaction> beginTransaction = SettableApiFuture.create();
256+
SettableApiFuture<CommitResponse> inFlightCommit =
257+
setUpTransactionThatCommitsAfterBeginTransaction(beginTransaction);
258+
TransactionContextImpl transaction = newTransactionWithoutTransactionId();
259+
260+
ApiFuture<com.google.cloud.spanner.CommitResponse> commitFuture = transaction.commitAsync();
261+
// The Commit RPC is only sent once BeginTransaction has finished, so cancelling here means
262+
// that the commit is abandoned before there is any RPC to cancel.
263+
assertTrue(commitFuture.cancel(true));
264+
verify(rpc, never()).commitAsync(Mockito.any(CommitRequest.class), Mockito.anyMap());
265+
266+
beginTransaction.set(
267+
Transaction.newBuilder().setId(ByteString.copyFromUtf8("test-txn")).build());
268+
269+
assertTrue(
270+
"Commit RPC that was sent after the cancellation was not cancelled",
271+
inFlightCommit.isCancelled());
272+
}
273+
274+
@Test
275+
public void commitCancelsCommitRpcThatIsSentAfterCallingThreadInterrupted() {
276+
SettableApiFuture<Transaction> beginTransaction = SettableApiFuture.create();
277+
SettableApiFuture<CommitResponse> inFlightCommit =
278+
setUpTransactionThatCommitsAfterBeginTransaction(beginTransaction);
279+
TransactionContextImpl transaction = newTransactionWithoutTransactionId();
280+
281+
try {
282+
// Interrupting before commit() waits for the result means that it gives up while
283+
// BeginTransaction is still pending, and therefore before the Commit RPC has been sent.
284+
Thread.currentThread().interrupt();
285+
SpannerException e = assertThrows(SpannerException.class, transaction::commit);
286+
assertEquals(ErrorCode.CANCELLED, e.getErrorCode());
287+
verify(rpc, never()).commitAsync(Mockito.any(CommitRequest.class), Mockito.anyMap());
288+
289+
// commit() restores the interrupt flag, and BeginTransaction finishes on a gax thread that
290+
// does not have that flag. Clear it so the listeners below are not interrupted either.
291+
Thread.interrupted();
292+
beginTransaction.set(
293+
Transaction.newBuilder().setId(ByteString.copyFromUtf8("test-txn")).build());
294+
295+
verify(rpc, times(1)).commitAsync(Mockito.any(CommitRequest.class), Mockito.anyMap());
296+
assertTrue(
297+
"Commit RPC that was sent after the interrupt was not cancelled",
298+
inFlightCommit.isCancelled());
299+
} finally {
300+
// Clear the interrupt flag so it cannot leak into other tests.
301+
Thread.interrupted();
302+
}
303+
}
304+
305+
/**
306+
* Sets up a session that only finishes BeginTransaction once the given future is set, and returns
307+
* the future of the Commit RPC that is sent after that.
308+
*/
309+
private SettableApiFuture<CommitResponse> setUpTransactionThatCommitsAfterBeginTransaction(
310+
SettableApiFuture<Transaction> beginTransaction) {
311+
when(session.getName()).thenReturn("projects/p/instances/i/databases/d/sessions/s");
312+
when(session.beginTransactionAsync(
313+
Mockito.any(Options.class),
314+
Mockito.anyBoolean(),
315+
Mockito.anyMap(),
316+
Mockito.any(),
317+
Mockito.any()))
318+
.thenReturn(beginTransaction);
319+
SettableApiFuture<CommitResponse> inFlightCommit = SettableApiFuture.create();
320+
when(rpc.commitAsync(Mockito.any(CommitRequest.class), Mockito.anyMap()))
321+
.thenReturn(inFlightCommit);
322+
return inFlightCommit;
323+
}
324+
325+
/** Returns a transaction that has to call BeginTransaction before it can commit. */
326+
private TransactionContextImpl newTransactionWithoutTransactionId() {
327+
return TransactionContextImpl.newBuilder()
328+
.setSession(session)
329+
.setOptions(Options.fromTransactionOptions())
330+
.setRpc(rpc)
331+
.setTracer(tracer)
332+
.setSpan(span)
333+
.build();
334+
}
335+
198336
@SuppressWarnings("unchecked")
199337
@Test
200338
public void usesPreparedTransaction() {

0 commit comments

Comments
 (0)