com.google.cloud.spanner.TransactionRunnerImpl Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of google-cloud-spanner Show documentation
Show all versions of google-cloud-spanner Show documentation
Java idiomatic client for Google Cloud Spanner.
/*
* Copyright 2019 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.cloud.spanner;
import static com.google.cloud.spanner.SpannerExceptionFactory.newSpannerBatchUpdateException;
import static com.google.cloud.spanner.SpannerExceptionFactory.newSpannerException;
import static com.google.cloud.spanner.SpannerImpl.BATCH_UPDATE;
import static com.google.cloud.spanner.SpannerImpl.UPDATE;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;
import com.google.api.core.ApiFuture;
import com.google.api.core.ApiFutures;
import com.google.api.core.SettableApiFuture;
import com.google.cloud.Timestamp;
import com.google.cloud.spanner.Options.QueryOption;
import com.google.cloud.spanner.Options.ReadOption;
import com.google.cloud.spanner.Options.TransactionOption;
import com.google.cloud.spanner.Options.UpdateOption;
import com.google.cloud.spanner.SessionImpl.SessionTransaction;
import com.google.cloud.spanner.spi.v1.SpannerRpc;
import com.google.cloud.spanner.spi.v1.SpannerRpc.Option;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableMap;
import com.google.common.util.concurrent.MoreExecutors;
import com.google.protobuf.ByteString;
import com.google.protobuf.Empty;
import com.google.rpc.Code;
import com.google.spanner.v1.CommitRequest;
import com.google.spanner.v1.ExecuteBatchDmlRequest;
import com.google.spanner.v1.ExecuteBatchDmlResponse;
import com.google.spanner.v1.ExecuteSqlRequest;
import com.google.spanner.v1.ExecuteSqlRequest.QueryMode;
import com.google.spanner.v1.RequestOptions;
import com.google.spanner.v1.ResultSet;
import com.google.spanner.v1.ResultSetStats;
import com.google.spanner.v1.RollbackRequest;
import com.google.spanner.v1.Transaction;
import com.google.spanner.v1.TransactionOptions;
import com.google.spanner.v1.TransactionSelector;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.annotation.Nullable;
import javax.annotation.concurrent.GuardedBy;
/** Default implementation of {@link TransactionRunner}. */
class TransactionRunnerImpl implements SessionTransaction, TransactionRunner {
private static final Logger txnLogger = Logger.getLogger(TransactionRunner.class.getName());
/**
* (Part of) the error message that is returned by Cloud Spanner if a transaction is cancelled
* because it was invalidated by a later transaction in the same session.
*/
private static final String TRANSACTION_CANCELLED_MESSAGE = "invalidated by a later transaction";
private static final String TRANSACTION_ALREADY_COMMITTED_MESSAGE =
"Transaction has already committed";
private static final String DML_INVALID_EXCLUDE_CHANGE_STREAMS_OPTION_MESSAGE =
"Options.excludeTxnFromChangeStreams() cannot be specified for individual DML requests. "
+ "This option should be set at the transaction level.";
@VisibleForTesting
static class TransactionContextImpl extends AbstractReadContext implements TransactionContext {
static class Builder extends AbstractReadContext.Builder {
private Clock clock = new Clock();
private ByteString transactionId;
private Options options;
private boolean trackTransactionStarter;
private Builder() {}
Builder setClock(Clock clock) {
this.clock = Preconditions.checkNotNull(clock);
return self();
}
Builder setTransactionId(ByteString transactionId) {
this.transactionId = transactionId;
return self();
}
Builder setOptions(Options options) {
this.options = Preconditions.checkNotNull(options);
return self();
}
Builder setTrackTransactionStarter(boolean trackTransactionStarter) {
this.trackTransactionStarter = trackTransactionStarter;
return self();
}
@Override
TransactionContextImpl build() {
Preconditions.checkState(this.options != null, "Options must be set");
return new TransactionContextImpl(this);
}
}
static Builder newBuilder() {
return new Builder();
}
/**
* {@link AsyncResultSet} implementation that keeps track of the async operations that are still
* running for this {@link TransactionContext} and that should finish before the {@link
* TransactionContext} can commit and release its session back into the pool.
*/
private class TransactionContextAsyncResultSetImpl extends ForwardingAsyncResultSet
implements ListenableAsyncResultSet {
private TransactionContextAsyncResultSetImpl(ListenableAsyncResultSet delegate) {
super(delegate);
}
@Override
public ApiFuture setCallback(Executor exec, ReadyCallback cb) {
Runnable listener = TransactionContextImpl.this::decreaseAsyncOperations;
try {
increaseAsyncOperations();
addListener(listener);
return super.setCallback(exec, cb);
} catch (Throwable t) {
removeListener(listener);
decreaseAsyncOperations();
throw t;
}
}
@Override
public void addListener(Runnable listener) {
((ListenableAsyncResultSet) getDelegate()).addListener(listener);
}
@Override
public void removeListener(Runnable listener) {
((ListenableAsyncResultSet) getDelegate()).removeListener(listener);
}
}
private final Object committingLock = new Object();
@GuardedBy("committingLock")
private volatile boolean committing;
@GuardedBy("lock")
private volatile SettableApiFuture finishedAsyncOperations = SettableApiFuture.create();
@GuardedBy("lock")
private volatile int runningAsyncOperations;
private final Queue mutations = new ConcurrentLinkedQueue<>();
@GuardedBy("lock")
private boolean aborted;
private final Options options;
/** Default to -1 to indicate not available. */
@GuardedBy("lock")
private long retryDelayInMillis = -1L;
/**
* transactionIdFuture will return the transaction id returned by the first statement in the
* transaction if the BeginTransaction option is included with the first statement of the
* transaction.
*/
@VisibleForTesting volatile SettableApiFuture transactionIdFuture = null;
@VisibleForTesting long waitForTransactionTimeoutMillis = 60_000L;
private final boolean trackTransactionStarter;
private Exception transactionStarter;
volatile ByteString transactionId;
private CommitResponse commitResponse;
private final Clock clock;
private final Map channelHint;
private TransactionContextImpl(Builder builder) {
super(builder);
this.transactionId = builder.transactionId;
this.trackTransactionStarter = builder.trackTransactionStarter;
this.options = builder.options;
this.finishedAsyncOperations.set(null);
this.clock = builder.clock;
this.channelHint =
getChannelHintOptions(
session.getOptions(), ThreadLocalRandom.current().nextLong(Long.MAX_VALUE));
}
@Override
protected boolean isReadOnly() {
return false;
}
@Override
protected boolean isRouteToLeader() {
return true;
}
private void increaseAsyncOperations() {
synchronized (lock) {
if (runningAsyncOperations == 0) {
finishedAsyncOperations = SettableApiFuture.create();
}
runningAsyncOperations++;
}
}
private void decreaseAsyncOperations() {
synchronized (lock) {
runningAsyncOperations--;
if (runningAsyncOperations == 0) {
finishedAsyncOperations.set(null);
}
}
}
@Override
public void close() {
// Only mark the context as closed, but do not end the tracer span, as that is done by the
// commit and rollback methods.
synchronized (lock) {
isClosed = true;
}
}
void ensureTxn() {
try {
ensureTxnAsync().get();
} catch (ExecutionException e) {
throw SpannerExceptionFactory.newSpannerException(e.getCause() == null ? e : e.getCause());
} catch (InterruptedException e) {
throw SpannerExceptionFactory.propagateInterrupt(e);
}
}
ApiFuture ensureTxnAsync() {
final SettableApiFuture res = SettableApiFuture.create();
if (transactionId == null || isAborted()) {
createTxnAsync(res);
} else {
span.addAnnotation("Transaction Initialized", "Id", transactionId.toStringUtf8());
txnLogger.log(
Level.FINER,
"Using prepared transaction {0}",
txnLogger.isLoggable(Level.FINER) ? transactionId.asReadOnlyByteBuffer() : null);
res.set(null);
}
return res;
}
private void createTxnAsync(final SettableApiFuture res) {
span.addAnnotation("Creating Transaction");
final ApiFuture fut =
session.beginTransactionAsync(options, isRouteToLeader(), getTransactionChannelHint());
fut.addListener(
() -> {
try {
transactionId = fut.get();
span.addAnnotation("Transaction Creation Done", "Id", transactionId.toStringUtf8());
txnLogger.log(
Level.FINER,
"Started transaction {0}",
txnLogger.isLoggable(Level.FINER) ? transactionId.asReadOnlyByteBuffer() : null);
res.set(null);
} catch (ExecutionException e) {
span.addAnnotation(
"Transaction Creation Failed", e.getCause() == null ? e : e.getCause());
res.setException(e.getCause() == null ? e : e.getCause());
} catch (InterruptedException e) {
res.setException(SpannerExceptionFactory.propagateInterrupt(e));
}
},
MoreExecutors.directExecutor());
}
void commit() {
try {
// Normally, Gax will take care of any timeouts, but we add a timeout for getting the value
// from the future here as well to make sure the call always finishes, even if the future
// never resolves.
commitResponse =
commitAsync()
.get(
rpc.getCommitRetrySettings().getTotalTimeout().getSeconds() + 5,
TimeUnit.SECONDS);
} catch (InterruptedException | TimeoutException e) {
if (commitFuture != null) {
commitFuture.cancel(true);
}
if (e instanceof InterruptedException) {
throw SpannerExceptionFactory.propagateInterrupt((InterruptedException) e);
} else {
throw SpannerExceptionFactory.propagateTimeout((TimeoutException) e);
}
} catch (ExecutionException e) {
throw SpannerExceptionFactory.newSpannerException(e.getCause() == null ? e : e.getCause());
}
}
volatile ApiFuture commitFuture;
ApiFuture commitAsync() {
close();
List mutationsProto = new ArrayList<>();
synchronized (committingLock) {
if (committing) {
throw new IllegalStateException(TRANSACTION_ALREADY_COMMITTED_MESSAGE);
}
committing = true;
if (!mutations.isEmpty()) {
Mutation.toProto(mutations, mutationsProto);
}
}
final SettableApiFuture res = SettableApiFuture.create();
final SettableApiFuture finishOps;
CommitRequest.Builder builder =
CommitRequest.newBuilder()
.setSession(session.getName())
.setReturnCommitStats(options.withCommitStats());
if (options.hasPriority() || getTransactionTag() != null) {
RequestOptions.Builder requestOptionsBuilder = RequestOptions.newBuilder();
if (options.hasPriority()) {
requestOptionsBuilder.setPriority(options.priority());
}
if (getTransactionTag() != null) {
requestOptionsBuilder.setTransactionTag(getTransactionTag());
}
builder.setRequestOptions(requestOptionsBuilder.build());
}
if (options.hasMaxCommitDelay()) {
builder.setMaxCommitDelay(
com.google.protobuf.Duration.newBuilder()
.setSeconds(options.maxCommitDelay().getSeconds())
.setNanos(options.maxCommitDelay().getNano())
.build());
}
synchronized (lock) {
if (transactionIdFuture == null && transactionId == null && runningAsyncOperations == 0) {
finishOps = SettableApiFuture.create();
createTxnAsync(finishOps);
} else {
finishOps = finishedAsyncOperations;
}
}
builder.addAllMutations(mutationsProto);
finishOps.addListener(
new CommitRunnable(res, finishOps, builder), MoreExecutors.directExecutor());
return res;
}
private final class CommitRunnable implements Runnable {
private final SettableApiFuture res;
private final ApiFuture prev;
private final CommitRequest.Builder requestBuilder;
CommitRunnable(
SettableApiFuture res,
ApiFuture prev,
CommitRequest.Builder requestBuilder) {
this.res = res;
this.prev = prev;
this.requestBuilder = requestBuilder;
}
@Override
public void run() {
try {
prev.get();
if (transactionId == null && transactionIdFuture == null) {
requestBuilder.setSingleUseTransaction(
TransactionOptions.newBuilder()
.setReadWrite(TransactionOptions.ReadWrite.getDefaultInstance())
.setExcludeTxnFromChangeStreams(
options.withExcludeTxnFromChangeStreams() == Boolean.TRUE));
} else {
requestBuilder.setTransactionId(
transactionId == null
? transactionIdFuture.get(
waitForTransactionTimeoutMillis, TimeUnit.MILLISECONDS)
: transactionId);
}
if (options.hasPriority() || getTransactionTag() != null) {
RequestOptions.Builder requestOptionsBuilder = RequestOptions.newBuilder();
if (options.hasPriority()) {
requestOptionsBuilder.setPriority(options.priority());
}
if (getTransactionTag() != null) {
requestOptionsBuilder.setTransactionTag(getTransactionTag());
}
requestBuilder.setRequestOptions(requestOptionsBuilder.build());
}
final CommitRequest commitRequest = requestBuilder.build();
span.addAnnotation("Starting Commit");
final ApiFuture commitFuture;
final ISpan opSpan = tracer.spanBuilderWithExplicitParent(SpannerImpl.COMMIT, span);
try (IScope ignore = tracer.withSpan(opSpan)) {
commitFuture = rpc.commitAsync(commitRequest, getTransactionChannelHint());
}
session.markUsed(clock.instant());
commitFuture.addListener(
() -> {
try (IScope ignore = tracer.withSpan(opSpan)) {
if (!commitFuture.isDone()) {
// This should not be possible, considering that we are in a listener for the
// future, but we add a result here as well as a safety precaution.
res.setException(
SpannerExceptionFactory.newSpannerException(
ErrorCode.INTERNAL, "commitFuture is not done"));
return;
}
com.google.spanner.v1.CommitResponse proto = commitFuture.get();
if (!proto.hasCommitTimestamp()) {
throw newSpannerException(
ErrorCode.INTERNAL, "Missing commitTimestamp:\n" + session.getName());
}
span.addAnnotation("Commit Done");
opSpan.end();
res.set(new CommitResponse(proto));
} catch (Throwable throwable) {
SpannerException resultException;
try {
if (throwable instanceof ExecutionException) {
resultException =
SpannerExceptionFactory.asSpannerException(
throwable.getCause() == null ? throwable : throwable.getCause());
} else if (throwable instanceof InterruptedException) {
resultException =
SpannerExceptionFactory.propagateInterrupt(
(InterruptedException) throwable);
} else {
resultException = SpannerExceptionFactory.asSpannerException(throwable);
}
span.addAnnotation("Commit Failed", resultException);
opSpan.setStatus(resultException);
opSpan.end();
res.setException(onError(resultException, false));
} catch (Throwable unexpectedError) {
// This is a safety precaution to make sure that a result is always returned.
res.setException(unexpectedError);
}
}
},
MoreExecutors.directExecutor());
} catch (InterruptedException e) {
res.setException(SpannerExceptionFactory.propagateInterrupt(e));
} catch (TimeoutException e) {
res.setException(SpannerExceptionFactory.propagateTimeout(e));
} catch (Throwable e) {
res.setException(
SpannerExceptionFactory.newSpannerException(e.getCause() == null ? e : e.getCause()));
}
}
}
CommitResponse getCommitResponse() {
checkState(commitResponse != null, "run() has not yet returned normally");
return commitResponse;
}
boolean isAborted() {
synchronized (lock) {
return aborted;
}
}
void rollback() {
try {
rollbackAsync().get();
} catch (ExecutionException e) {
txnLogger.log(Level.FINE, "Exception during rollback", e);
span.addAnnotation("Rollback Failed", e);
} catch (InterruptedException e) {
throw SpannerExceptionFactory.propagateInterrupt(e);
}
}
ApiFuture rollbackAsync() {
close();
// It could be that there is no transaction if the transaction has been marked
// withInlineBegin, and there has not been any query/update statement that has been executed.
// In that case, we do not need to do anything, as there is no transaction.
//
// We do not take the transactionLock before trying to rollback to prevent a rollback call
// from blocking if an async query or update statement that is trying to begin the transaction
// is still in flight. That transaction will then automatically be terminated by the server.
if (transactionId != null) {
span.addAnnotation("Starting Rollback");
ApiFuture apiFuture =
rpc.rollbackAsync(
RollbackRequest.newBuilder()
.setSession(session.getName())
.setTransactionId(transactionId)
.build(),
getTransactionChannelHint());
session.markUsed(clock.instant());
return apiFuture;
} else {
return ApiFutures.immediateFuture(Empty.getDefaultInstance());
}
}
@Nullable
@Override
TransactionSelector getTransactionSelector() {
// Check if there is already a transactionId available. That is the case if this transaction
// has already been prepared by the session pool, or if this transaction has been marked
// withInlineBegin and an earlier statement has already started a transaction.
if (transactionId == null) {
try {
ApiFuture tx = null;
synchronized (lock) {
// The first statement of a transaction that gets here will be the one that includes
// BeginTransaction with the statement. The others will be waiting on the
// transactionIdFuture until an actual transactionId is available.
if (transactionIdFuture == null) {
transactionIdFuture = SettableApiFuture.create();
if (trackTransactionStarter) {
transactionStarter = new Exception("Requesting new transaction");
}
} else {
tx = transactionIdFuture;
}
}
if (tx == null) {
return TransactionSelector.newBuilder()
.setBegin(SessionImpl.createReadWriteTransactionOptions(options))
.build();
} else {
// Wait for the transaction to come available. The tx.get() call will fail with an
// Aborted error if the call that included the BeginTransaction option fails. The
// Aborted error will cause the entire transaction to be retried, and the retry will use
// a separate BeginTransaction RPC.
// If tx.get() returns successfully, this.transactionId will also have been set to a
// valid value as the latter is always set when a transaction id is returned by a
// statement.
return TransactionSelector.newBuilder()
.setId(tx.get(waitForTransactionTimeoutMillis, TimeUnit.MILLISECONDS))
.build();
}
} catch (ExecutionException e) {
if (e.getCause() instanceof AbortedException) {
synchronized (lock) {
aborted = true;
}
}
throw SpannerExceptionFactory.newSpannerException(e.getCause());
} catch (TimeoutException e) {
// Throw an ABORTED exception to force a retry of the transaction if no transaction
// has been returned by the first statement.
SpannerException se =
SpannerExceptionFactory.newSpannerException(
ErrorCode.ABORTED,
"Timeout while waiting for a transaction to be returned by another statement."
+ (trackTransactionStarter
? " See the suppressed exception for the stacktrace of the caller that should return a transaction"
: ""),
e);
if (transactionStarter != null) {
se.addSuppressed(transactionStarter);
}
throw se;
} catch (InterruptedException e) {
throw SpannerExceptionFactory.newSpannerExceptionForCancellation(null, e);
}
}
// There is already a transactionId available. Include that id as the transaction to use.
return TransactionSelector.newBuilder().setId(transactionId).build();
}
@Override
Map