Skip to content

Commit 2dfdec8

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 2dfdec8

5 files changed

Lines changed: 695 additions & 52 deletions

File tree

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

Lines changed: 211 additions & 51 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() {
472-
if (pendingStarts.decrementAndGet() == 0) {
473-
txnLock.lock();
474-
try {
508+
SettableApiFuture<Void> futureToComplete = null;
509+
Throwable error = null;
510+
txnLock.lock();
511+
try {
512+
if (pendingStarts.decrementAndGet() == 0) {
475513
hasNoPendingStarts.signalAll();
476-
} finally {
477-
txnLock.unlock();
514+
if (closeFuture != null) {
515+
try {
516+
super.close();
517+
} catch (Throwable throwable) {
518+
error = throwable;
519+
}
520+
futureToComplete = closeFuture;
521+
}
522+
}
523+
} finally {
524+
txnLock.unlock();
525+
}
526+
if (futureToComplete != null) {
527+
if (error != null) {
528+
futureToComplete.setException(error);
529+
} else {
530+
futureToComplete.set(null);
478531
}
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,65 @@ private TransactionOptions createReadOnlyTransactionOptions() {
686801
* Multiplexed Session.
687802
*/
688803
void initFallbackTransaction() {
689-
txnLock.lock();
690-
try {
691-
span.addAnnotation("Creating Transaction");
692-
final BeginTransactionRequest request =
693-
BeginTransactionRequest.newBuilder()
694-
.setSession(session.getName())
695-
.setOptions(createReadOnlyTransactionOptions())
696-
.build();
697-
initTransactionInternal(request);
698-
} finally {
699-
txnLock.unlock();
700-
}
804+
initTransaction();
701805
}
702806

807+
/**
808+
* Initializes the transaction by issuing a BeginTransaction RPC.
809+
*
810+
* <p>To prevent blocking concurrent operations (such as {@link #closeAsync()}) while a network
811+
* RPC is in-flight, {@code rpc.beginTransaction} is executed <b>outside</b> {@code txnLock}. A
812+
* leader/follower pattern using {@link #transactionIdFuture} is used: the first caller acquires
813+
* the lock, creates {@code transactionIdFuture}, releases the lock, and executes the RPC.
814+
* Subsequent concurrent callers retrieve {@code transactionIdFuture} and wait on it outside the
815+
* lock.
816+
*/
703817
void initTransaction() {
704818
SessionImpl.throwIfTransactionsPending();
705819

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.
820+
ApiFuture<ByteString> futureToWaitFor = null;
821+
BeginTransactionRequest request = null;
713822
txnLock.lock();
714823
try {
715824
if (transactionId != null) {
716825
return;
717826
}
718-
span.addAnnotation("Creating Transaction");
719-
final BeginTransactionRequest request =
720-
BeginTransactionRequest.newBuilder()
721-
.setSession(session.getName())
722-
.setOptions(createReadOnlyTransactionOptions())
723-
.build();
724-
initTransactionInternal(request);
827+
if (transactionIdFuture != null) {
828+
futureToWaitFor = transactionIdFuture;
829+
} else {
830+
transactionIdFuture = SettableApiFuture.create();
831+
span.addAnnotation("Creating Transaction");
832+
request =
833+
BeginTransactionRequest.newBuilder()
834+
.setSession(session.getName())
835+
.setOptions(createReadOnlyTransactionOptions())
836+
.build();
837+
}
725838
} finally {
726839
txnLock.unlock();
727840
}
841+
842+
if (futureToWaitFor != null) {
843+
try {
844+
futureToWaitFor.get();
845+
return;
846+
} catch (ExecutionException executionException) {
847+
throw SpannerExceptionFactory.asSpannerException(executionException.getCause());
848+
} catch (InterruptedException interruptedException) {
849+
Thread.currentThread().interrupt();
850+
throw SpannerExceptionFactory.newSpannerExceptionForCancellation(
851+
null, interruptedException);
852+
}
853+
}
854+
855+
initTransactionInternal(request);
728856
}
729857

858+
/**
859+
* Executes the BeginTransaction RPC outside {@code txnLock}, updates transaction state under
860+
* {@code txnLock}, and completes or fails {@link #transactionIdFuture} so waiting callers are
861+
* notified.
862+
*/
730863
private void initTransactionInternal(BeginTransactionRequest request) {
731864
try {
732865
Transaction transaction =
@@ -739,20 +872,41 @@ private void initTransactionInternal(BeginTransactionRequest request) {
739872
throw SpannerExceptionFactory.newSpannerException(
740873
ErrorCode.INTERNAL, "Missing expected transaction.id metadata field");
741874
}
875+
Timestamp readTimestamp;
742876
try {
743-
timestamp = Timestamp.fromProto(transaction.getReadTimestamp());
744-
} catch (IllegalArgumentException e) {
877+
readTimestamp = Timestamp.fromProto(transaction.getReadTimestamp());
878+
} catch (IllegalArgumentException illegalArgumentException) {
745879
throw SpannerExceptionFactory.newSpannerException(
746-
ErrorCode.INTERNAL, "Bad value in transaction.read_timestamp metadata field", e);
880+
ErrorCode.INTERNAL,
881+
"Bad value in transaction.read_timestamp metadata field",
882+
illegalArgumentException);
883+
}
884+
txnLock.lock();
885+
try {
886+
timestamp = readTimestamp;
887+
transactionId = transaction.getId();
888+
if (transactionIdFuture != null && !transactionIdFuture.isDone()) {
889+
transactionIdFuture.set(transactionId);
890+
}
891+
} finally {
892+
txnLock.unlock();
747893
}
748-
transactionId = transaction.getId();
749894
span.addAnnotation(
750895
"Transaction Creation Done",
751896
ImmutableMap.of(
752-
"Id", transaction.getId().toStringUtf8(), "Timestamp", timestamp.toString()));
753-
} catch (SpannerException e) {
754-
span.addAnnotation("Transaction Creation Failed", e);
755-
throw e;
897+
"Id", transaction.getId().toStringUtf8(), "Timestamp", readTimestamp.toString()));
898+
} catch (Throwable throwable) {
899+
SpannerException spannerException = SpannerExceptionFactory.asSpannerException(throwable);
900+
span.addAnnotation("Transaction Creation Failed", spannerException);
901+
txnLock.lock();
902+
try {
903+
if (transactionIdFuture != null && !transactionIdFuture.isDone()) {
904+
transactionIdFuture.setException(spannerException);
905+
}
906+
} finally {
907+
txnLock.unlock();
908+
}
909+
throw spannerException;
756910
}
757911
}
758912
}
@@ -1206,6 +1360,12 @@ public void close() {
12061360
}
12071361
}
12081362

1363+
@Override
1364+
public ApiFuture<Void> closeAsync() {
1365+
close();
1366+
return ApiFutures.immediateFuture(null);
1367+
}
1368+
12091369
/**
12101370
* Returns the {@link TransactionSelector} that should be used for a statement that is executed on
12111371
* 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)