Skip to content

Commit a151ebf

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 a151ebf

2 files changed

Lines changed: 194 additions & 4 deletions

File tree

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

Lines changed: 62 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,41 @@ 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+
// skips sending it, 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+
}
416+
417+
private boolean isCommitCancelled() {
418+
synchronized (commitCancellationLock) {
419+
return commitCancelled;
420+
}
421+
}
382422

383423
ApiFuture<CommitResponse> commitAsync() {
384424
close();
@@ -430,6 +470,16 @@ ApiFuture<CommitResponse> commitAsync() {
430470
new CommitRunnable(
431471
res, finishOps, builder, /* retryAttemptDueToCommitProtocolExtension= */ false),
432472
MoreExecutors.directExecutor());
473+
// Cancelling the future that is returned to the caller must also cancel the Commit RPC, in
474+
// the same way as the timeout/interrupt path in commit() does. This listener also runs when
475+
// the commit finishes normally, hence the isCancelled() check.
476+
res.addListener(
477+
() -> {
478+
if (res.isCancelled()) {
479+
cancelInFlightCommit();
480+
}
481+
},
482+
MoreExecutors.directExecutor());
433483
return res;
434484
}
435485

@@ -484,13 +534,21 @@ public void run() {
484534
"Retrying commit operation with a new precommit token obtained from the previous"
485535
+ " CommitResponse");
486536
}
537+
if (isCommitCancelled()) {
538+
res.setException(
539+
newSpannerException(
540+
ErrorCode.CANCELLED,
541+
"The commit was cancelled before the Commit RPC was sent"));
542+
return;
543+
}
487544
final CommitRequest commitRequest = requestBuilder.build();
488545
span.addAnnotation("Starting Commit");
489546
final ApiFuture<com.google.spanner.v1.CommitResponse> commitFuture;
490547
final ISpan opSpan = tracer.spanBuilderWithExplicitParent(SpannerImpl.COMMIT, span);
491548
try (IScope ignore = tracer.withSpan(opSpan)) {
492549
commitFuture = rpc.commitAsync(commitRequest, getTransactionChannelHint());
493550
}
551+
publishOrCancelInFlightCommit(commitFuture);
494552
session.markUsed(clock.instant());
495553
commitFuture.addListener(
496554
() -> {

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

Lines changed: 132 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,135 @@ 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 commitAsyncSkipsCommitRpcWhenCancelledBeforeItIsSent() {
255+
SettableApiFuture<Transaction> beginTransaction = SettableApiFuture.create();
256+
setUpTransactionThatCommitsAfterBeginTransaction(beginTransaction);
257+
TransactionContextImpl transaction = newTransactionWithoutTransactionId();
258+
259+
ApiFuture<com.google.cloud.spanner.CommitResponse> commitFuture = transaction.commitAsync();
260+
// The Commit RPC is only sent once BeginTransaction has finished, so cancelling here means
261+
// that the commit is abandoned before there is any RPC to cancel.
262+
assertTrue(commitFuture.cancel(true));
263+
verify(rpc, never()).commitAsync(Mockito.any(CommitRequest.class), Mockito.anyMap());
264+
265+
beginTransaction.set(
266+
Transaction.newBuilder().setId(ByteString.copyFromUtf8("test-txn")).build());
267+
268+
// The commit was already abandoned, so the Commit RPC should not be sent at all.
269+
verify(rpc, never()).commitAsync(Mockito.any(CommitRequest.class), Mockito.anyMap());
270+
}
271+
272+
@Test
273+
public void commitSkipsCommitRpcWhenCallingThreadInterruptedBeforeItIsSent() {
274+
SettableApiFuture<Transaction> beginTransaction = SettableApiFuture.create();
275+
setUpTransactionThatCommitsAfterBeginTransaction(beginTransaction);
276+
TransactionContextImpl transaction = newTransactionWithoutTransactionId();
277+
278+
try {
279+
// Interrupting before commit() waits for the result means that it gives up while
280+
// BeginTransaction is still pending, and therefore before the Commit RPC has been sent.
281+
Thread.currentThread().interrupt();
282+
SpannerException e = assertThrows(SpannerException.class, transaction::commit);
283+
assertEquals(ErrorCode.CANCELLED, e.getErrorCode());
284+
verify(rpc, never()).commitAsync(Mockito.any(CommitRequest.class), Mockito.anyMap());
285+
286+
// commit() restores the interrupt flag, and BeginTransaction finishes on a gax thread that
287+
// does not have that flag. Clear it so the listeners below are not interrupted either.
288+
Thread.interrupted();
289+
beginTransaction.set(
290+
Transaction.newBuilder().setId(ByteString.copyFromUtf8("test-txn")).build());
291+
292+
// The commit was already abandoned, so the Commit RPC should not be sent at all.
293+
verify(rpc, never()).commitAsync(Mockito.any(CommitRequest.class), Mockito.anyMap());
294+
} finally {
295+
// Clear the interrupt flag so it cannot leak into other tests.
296+
Thread.interrupted();
297+
}
298+
}
299+
300+
/**
301+
* Sets up a session that only finishes BeginTransaction once the given future is set. The Commit
302+
* RPC is also stubbed, so that a commit that is unexpectedly sent gets a well-defined future
303+
* instead of a {@code null} from Mockito, and fails the {@code verify(rpc, never())} assertions.
304+
*/
305+
private void setUpTransactionThatCommitsAfterBeginTransaction(
306+
SettableApiFuture<Transaction> beginTransaction) {
307+
when(session.getName()).thenReturn("projects/p/instances/i/databases/d/sessions/s");
308+
when(session.beginTransactionAsync(
309+
Mockito.any(Options.class),
310+
Mockito.anyBoolean(),
311+
Mockito.anyMap(),
312+
Mockito.any(),
313+
Mockito.any()))
314+
.thenReturn(beginTransaction);
315+
when(rpc.commitAsync(Mockito.any(CommitRequest.class), Mockito.anyMap()))
316+
.thenReturn(SettableApiFuture.create());
317+
}
318+
319+
/** Returns a transaction that has to call BeginTransaction before it can commit. */
320+
private TransactionContextImpl newTransactionWithoutTransactionId() {
321+
return TransactionContextImpl.newBuilder()
322+
.setSession(session)
323+
.setOptions(Options.fromTransactionOptions())
324+
.setRpc(rpc)
325+
.setTracer(tracer)
326+
.setSpan(span)
327+
.build();
328+
}
329+
198330
@SuppressWarnings("unchecked")
199331
@Test
200332
public void usesPreparedTransaction() {

0 commit comments

Comments
 (0)