Skip to content

Commit 98b98d9

Browse files
committed
fix(spanner): add closeAsync to ReadContext and make transaction closing non-blocking
Adds `ReadContext.closeAsync()` to allow closing read contexts asynchronously without blocking caller threads. Refactors `MultiUseReadOnlyTransaction` so that background query initializations and `BeginTransaction` RPCs do not block thread execution during close, while preserving legacy synchronous `close()` semantics and guarding against new operations during closing.
1 parent 84b2069 commit 98b98d9

5 files changed

Lines changed: 692 additions & 37 deletions

File tree

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

Lines changed: 208 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -358,9 +358,40 @@ static Builder newBuilder() {
358358
@GuardedBy("txnLock")
359359
private ByteString transactionId;
360360

361+
/**
362+
* Future used to synchronize concurrent operations during transaction initialization:
363+
*
364+
* <ul>
365+
* <li>When using explicit {@code BeginTransaction} (e.g. via {@link #initTransaction()}), the
366+
* first caller creates this future, releases {@code txnLock}, and executes the RPC
367+
* outside the lock. Subsequent concurrent callers wait on this future outside the lock.
368+
* <li>When using inlined {@code BeginTransaction} options, the first query or read operation
369+
* creates this future and includes {@code Begin} in its {@link TransactionSelector}.
370+
* Concurrent queries wait on this future until the first operation's initial response
371+
* arrives and sets the transaction ID via {@link #onTransactionMetadata}.
372+
* </ul>
373+
*/
361374
@GuardedBy("txnLock")
362375
private SettableApiFuture<ByteString> transactionIdFuture;
363376

377+
/**
378+
* Future completed when all in-flight asynchronous query initializations (tracked by {@link
379+
* #pendingStarts}) have finished and the read context has been closed.
380+
*/
381+
@GuardedBy("txnLock")
382+
private SettableApiFuture<Void> closeFuture;
383+
384+
/**
385+
* Flag indicating whether the transaction has been closed or is currently closing. Once true,
386+
* no new reads or queries (synchronous or asynchronous) are permitted to start on this context.
387+
*/
388+
@GuardedBy("txnLock")
389+
private boolean isClosedOrClosing;
390+
391+
/**
392+
* Counter tracking the number of asynchronous queries created on this transaction whose
393+
* delegate {@link ResultSet} suppliers have not yet been executed or closed.
394+
*/
364395
private final AtomicInteger pendingStarts = new AtomicInteger(0);
365396

366397
private static final long WAIT_FOR_INLINE_BEGIN_TIMEOUT_MILLIS = 60_000L;
@@ -468,20 +499,53 @@ TransactionSelector getTransactionSelector() {
468499
}
469500
}
470501

502+
/**
503+
* Decrements the count of pending asynchronous query starts. When the count drops to 0, if the
504+
* transaction is in the process of closing (i.e. {@link #closeAsync()} was called), this method
505+
* closes the read context via {@code super.close()} and completes {@link #closeFuture}.
506+
*/
471507
private void decrementPendingStartsAndSignal() {
472508
if (pendingStarts.decrementAndGet() == 0) {
509+
SettableApiFuture<Void> futureToComplete = null;
510+
Throwable error = null;
473511
txnLock.lock();
474512
try {
475513
hasNoPendingStarts.signalAll();
514+
if (closeFuture != null) {
515+
try {
516+
super.close();
517+
} catch (Throwable throwable) {
518+
error = throwable;
519+
}
520+
futureToComplete = closeFuture;
521+
}
476522
} finally {
477523
txnLock.unlock();
478524
}
525+
if (futureToComplete != null) {
526+
if (error != null) {
527+
futureToComplete.setException(error);
528+
} else {
529+
futureToComplete.set(null);
530+
}
531+
}
479532
}
480533
}
481534

535+
/**
536+
* Creates an {@link AsyncResultSetImpl} for an asynchronous read or query. Tracks the query in
537+
* {@link #pendingStarts} so that {@link #closeAsync()} will not close the read context until
538+
* the query initialization has completed.
539+
*/
482540
private ListenableAsyncResultSet createAsyncResultSet(
483541
Supplier<ResultSet> resultSetSupplier, int bufferRows) {
484-
pendingStarts.incrementAndGet();
542+
txnLock.lock();
543+
try {
544+
checkState(!isClosedOrClosing, "Context has been closed");
545+
pendingStarts.incrementAndGet();
546+
} finally {
547+
txnLock.unlock();
548+
}
485549
// Make sure that we decrement the counter exactly once, either
486550
// when the query is actually executed, or when the result set is closed,
487551
// or if something goes wrong when creating the result set.
@@ -518,6 +582,41 @@ public void close() {
518582
}
519583
}
520584

585+
/**
586+
* Ensures that the transaction is neither closed nor in the process of closing before
587+
* initiating a new synchronous read or query.
588+
*/
589+
private void checkClosedOrClosing() {
590+
txnLock.lock();
591+
try {
592+
checkState(!isClosedOrClosing, "Context has been closed");
593+
} finally {
594+
txnLock.unlock();
595+
}
596+
}
597+
598+
@Override
599+
ResultSet readInternalWithOptions(
600+
String table,
601+
@Nullable String index,
602+
KeySet keys,
603+
Iterable<String> columns,
604+
final Options readOptions,
605+
ByteString partitionToken) {
606+
checkClosedOrClosing();
607+
return super.readInternalWithOptions(
608+
table, index, keys, columns, readOptions, partitionToken);
609+
}
610+
611+
@Override
612+
ResultSet executeQueryInternal(
613+
final Statement statement,
614+
final com.google.spanner.v1.ExecuteSqlRequest.QueryMode queryMode,
615+
final QueryOption... options) {
616+
checkClosedOrClosing();
617+
return super.executeQueryInternal(statement, queryMode, options);
618+
}
619+
521620
@Override
522621
public ListenableAsyncResultSet readAsync(
523622
String table, KeySet keys, Iterable<String> columns, ReadOption... options) {
@@ -527,7 +626,7 @@ public ListenableAsyncResultSet readAsync(
527626
? readOptions.bufferRows()
528627
: AsyncResultSetImpl.DEFAULT_BUFFER_SIZE;
529628
return createAsyncResultSet(
530-
() -> readInternal(table, null, keys, columns, options), bufferRows);
629+
() -> super.readInternal(table, null, keys, columns, options), bufferRows);
531630
}
532631

533632
@Override
@@ -539,7 +638,7 @@ public ListenableAsyncResultSet readUsingIndexAsync(
539638
? readOptions.bufferRows()
540639
: AsyncResultSetImpl.DEFAULT_BUFFER_SIZE;
541640
return createAsyncResultSet(
542-
() -> readInternal(table, checkNotNull(index), keys, columns, options), bufferRows);
641+
() -> super.readInternal(table, checkNotNull(index), keys, columns, options), bufferRows);
543642
}
544643

545644
@Override
@@ -551,7 +650,7 @@ public ListenableAsyncResultSet executeQueryAsync(Statement statement, QueryOpti
551650
: AsyncResultSetImpl.DEFAULT_BUFFER_SIZE;
552651
return createAsyncResultSet(
553652
() ->
554-
executeQueryInternal(
653+
super.executeQueryInternal(
555654
statement, com.google.spanner.v1.ExecuteSqlRequest.QueryMode.NORMAL, options),
556655
bufferRows);
557656
}
@@ -650,21 +749,37 @@ ByteString getTransactionId() {
650749
}
651750
}
652751

752+
/**
753+
* Closes the transaction asynchronously.
754+
*
755+
* <p>If there are no in-flight asynchronous query initializations ({@code pendingStarts == 0}),
756+
* the read context is closed immediately and a completed future is returned. If there are
757+
* pending starts, this method marks {@code isClosedOrClosing = true} and returns a {@link
758+
* SettableApiFuture} that will be completed by {@link #decrementPendingStartsAndSignal()} when
759+
* all pending query initializations finish.
760+
*/
653761
@Override
654-
public void close() {
762+
public ApiFuture<Void> closeAsync() {
655763
txnLock.lock();
656764
try {
657-
while (pendingStarts.get() > 0) {
658-
try {
659-
hasNoPendingStarts.await();
660-
} catch (InterruptedException e) {
661-
throw SpannerExceptionFactory.propagateInterrupt(e);
662-
}
765+
if (isClosedOrClosing) {
766+
return closeFuture != null ? closeFuture : ApiFutures.immediateFuture(null);
767+
}
768+
isClosedOrClosing = true;
769+
if (pendingStarts.get() == 0) {
770+
super.close();
771+
return ApiFutures.immediateFuture(null);
663772
}
773+
closeFuture = SettableApiFuture.create();
774+
return closeFuture;
664775
} finally {
665776
txnLock.unlock();
666777
}
667-
super.close();
778+
}
779+
780+
@Override
781+
public void close() {
782+
SpannerApiFutures.get(closeAsync());
668783
}
669784

670785
private TransactionOptions createReadOnlyTransactionOptions() {
@@ -686,47 +801,77 @@ private TransactionOptions createReadOnlyTransactionOptions() {
686801
* Multiplexed Session.
687802
*/
688803
void initFallbackTransaction() {
804+
BeginTransactionRequest request;
689805
txnLock.lock();
690806
try {
691807
span.addAnnotation("Creating Transaction");
692-
final BeginTransactionRequest request =
808+
request =
693809
BeginTransactionRequest.newBuilder()
694810
.setSession(session.getName())
695811
.setOptions(createReadOnlyTransactionOptions())
696812
.build();
697-
initTransactionInternal(request);
698813
} finally {
699814
txnLock.unlock();
700815
}
816+
initTransactionInternal(request);
701817
}
702818

819+
/**
820+
* Initializes the transaction by issuing a BeginTransaction RPC.
821+
*
822+
* <p>To prevent blocking concurrent operations (such as {@link #closeAsync()}) while a network
823+
* RPC is in-flight, {@code rpc.beginTransaction} is executed <b>outside</b> {@code txnLock}. A
824+
* leader/follower pattern using {@link #transactionIdFuture} is used: the first caller acquires
825+
* the lock, creates {@code transactionIdFuture}, releases the lock, and executes the RPC.
826+
* Subsequent concurrent callers retrieve {@code transactionIdFuture} and wait on it outside the
827+
* lock.
828+
*/
703829
void initTransaction() {
704830
SessionImpl.throwIfTransactionsPending();
705831

706-
// Since we only support synchronous calls, just block on "txnLock" while the RPC is in
707-
// flight. Note that we use the strategy of sending an explicit BeginTransaction() RPC,
708-
// rather than using the first read in the transaction to begin it implicitly. The chosen
709-
// strategy is sub-optimal in the case of the first read being fast, as it incurs an extra
710-
// RTT, but optimal if the first read is slow. As the client library is now using streaming
711-
// reads, a possible optimization could be to use the first read in the transaction to begin
712-
// it implicitly.
832+
ApiFuture<ByteString> futureToWaitFor = null;
833+
BeginTransactionRequest request = null;
713834
txnLock.lock();
714835
try {
715836
if (transactionId != null) {
716837
return;
717838
}
718-
span.addAnnotation("Creating Transaction");
719-
final BeginTransactionRequest request =
720-
BeginTransactionRequest.newBuilder()
721-
.setSession(session.getName())
722-
.setOptions(createReadOnlyTransactionOptions())
723-
.build();
724-
initTransactionInternal(request);
839+
if (transactionIdFuture != null) {
840+
futureToWaitFor = transactionIdFuture;
841+
} else {
842+
transactionIdFuture = SettableApiFuture.create();
843+
span.addAnnotation("Creating Transaction");
844+
request =
845+
BeginTransactionRequest.newBuilder()
846+
.setSession(session.getName())
847+
.setOptions(createReadOnlyTransactionOptions())
848+
.build();
849+
}
725850
} finally {
726851
txnLock.unlock();
727852
}
853+
854+
if (futureToWaitFor != null) {
855+
try {
856+
futureToWaitFor.get();
857+
return;
858+
} catch (ExecutionException executionException) {
859+
throw SpannerExceptionFactory.asSpannerException(executionException.getCause());
860+
} catch (InterruptedException interruptedException) {
861+
Thread.currentThread().interrupt();
862+
throw SpannerExceptionFactory.newSpannerExceptionForCancellation(
863+
null, interruptedException);
864+
}
865+
}
866+
867+
initTransactionInternal(request);
728868
}
729869

870+
/**
871+
* Executes the BeginTransaction RPC outside {@code txnLock}, updates transaction state under
872+
* {@code txnLock}, and completes or fails {@link #transactionIdFuture} so waiting callers are
873+
* notified.
874+
*/
730875
private void initTransactionInternal(BeginTransactionRequest request) {
731876
try {
732877
Transaction transaction =
@@ -739,20 +884,41 @@ private void initTransactionInternal(BeginTransactionRequest request) {
739884
throw SpannerExceptionFactory.newSpannerException(
740885
ErrorCode.INTERNAL, "Missing expected transaction.id metadata field");
741886
}
887+
Timestamp readTimestamp;
742888
try {
743-
timestamp = Timestamp.fromProto(transaction.getReadTimestamp());
744-
} catch (IllegalArgumentException e) {
889+
readTimestamp = Timestamp.fromProto(transaction.getReadTimestamp());
890+
} catch (IllegalArgumentException illegalArgumentException) {
745891
throw SpannerExceptionFactory.newSpannerException(
746-
ErrorCode.INTERNAL, "Bad value in transaction.read_timestamp metadata field", e);
892+
ErrorCode.INTERNAL,
893+
"Bad value in transaction.read_timestamp metadata field",
894+
illegalArgumentException);
895+
}
896+
txnLock.lock();
897+
try {
898+
timestamp = readTimestamp;
899+
transactionId = transaction.getId();
900+
if (transactionIdFuture != null && !transactionIdFuture.isDone()) {
901+
transactionIdFuture.set(transactionId);
902+
}
903+
} finally {
904+
txnLock.unlock();
747905
}
748-
transactionId = transaction.getId();
749906
span.addAnnotation(
750907
"Transaction Creation Done",
751908
ImmutableMap.of(
752-
"Id", transaction.getId().toStringUtf8(), "Timestamp", timestamp.toString()));
753-
} catch (SpannerException e) {
754-
span.addAnnotation("Transaction Creation Failed", e);
755-
throw e;
909+
"Id", transaction.getId().toStringUtf8(), "Timestamp", readTimestamp.toString()));
910+
} catch (Throwable throwable) {
911+
SpannerException spannerException = SpannerExceptionFactory.asSpannerException(throwable);
912+
span.addAnnotation("Transaction Creation Failed", spannerException);
913+
txnLock.lock();
914+
try {
915+
if (transactionIdFuture != null && !transactionIdFuture.isDone()) {
916+
transactionIdFuture.setException(spannerException);
917+
}
918+
} finally {
919+
txnLock.unlock();
920+
}
921+
throw spannerException;
756922
}
757923
}
758924
}
@@ -1206,6 +1372,12 @@ public void close() {
12061372
}
12071373
}
12081374

1375+
@Override
1376+
public ApiFuture<Void> closeAsync() {
1377+
close();
1378+
return ApiFutures.immediateFuture(null);
1379+
}
1380+
12091381
/**
12101382
* Returns the {@link TransactionSelector} that should be used for a statement that is executed on
12111383
* this read context. This could be a reference to an existing transaction ID, or it could be a

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,16 @@ public void close() {
139139
}
140140
}
141141

142+
@Override
143+
public ApiFuture<Void> closeAsync() {
144+
return ApiFutures.catchingAsync(
145+
ApiFutures.transformAsync(
146+
this.readContextFuture, ReadContext::closeAsync, MoreExecutors.directExecutor()),
147+
Throwable.class,
148+
throwable -> ApiFutures.immediateFuture(null),
149+
MoreExecutors.directExecutor());
150+
}
151+
142152
/**
143153
* Represents a {@link ReadContext} using a multiplexed session that is not yet ready. The
144154
* execution will be delayed until the multiplexed session has been created and is ready.

0 commit comments

Comments
 (0)