From c25dca3ed6ca0c156ec60569ebc9f3a481bd4fee Mon Sep 17 00:00:00 2001 From: BenWhitehead Date: Wed, 12 Aug 2020 15:21:11 -0400 Subject: [PATCH] feat: add support for read-only transactions in TransactionOptions (#320) * Add new typesafe builders for read-only `TransactionOptions.readOnlyOptionsBuilder` and read-write `TransactionOptions.readWriteOptionsBuilder` transactions in TransactionOptions * These new builders ensure only those parameters relevant to the respective type of transaction are available * Deprecate existing `TransactionOptions.create(...)` methods in favor of the new builders * Update Transaction and TransactionRunner to use TransactionOptions directly --- google-cloud-firestore/pom.xml | 6 + .../google/cloud/firestore/FirestoreImpl.java | 10 +- .../google/cloud/firestore/Transaction.java | 23 +- .../cloud/firestore/TransactionOptions.java | 255 +++++++++++++++++- .../cloud/firestore/TransactionRunner.java | 27 +- .../cloud/firestore/TransactionTest.java | 85 +++++- .../cloud/firestore/it/ITSystemTest.java | 96 +++++++ 7 files changed, 463 insertions(+), 39 deletions(-) diff --git a/google-cloud-firestore/pom.xml b/google-cloud-firestore/pom.xml index 5e0018903..175b4470a 100644 --- a/google-cloud-firestore/pom.xml +++ b/google-cloud-firestore/pom.xml @@ -152,6 +152,12 @@ 2.11.2 test + + org.apache.commons + commons-lang3 + 3.11 + test + diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/FirestoreImpl.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/FirestoreImpl.java index 717cf8fe4..1fa2f756b 100644 --- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/FirestoreImpl.java +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/FirestoreImpl.java @@ -30,7 +30,6 @@ import com.google.firestore.v1.BatchGetDocumentsResponse; import com.google.firestore.v1.DatabaseRootName; import com.google.protobuf.ByteString; -import io.grpc.Context; import io.opencensus.trace.AttributeValue; import io.opencensus.trace.Tracer; import io.opencensus.trace.Tracing; @@ -40,7 +39,6 @@ import java.util.List; import java.util.Map; import java.util.Random; -import java.util.concurrent.Executor; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -310,15 +308,9 @@ public ApiFuture runAsyncTransaction( public ApiFuture runAsyncTransaction( @Nonnull final Transaction.AsyncFunction updateFunction, @Nonnull TransactionOptions transactionOptions) { - final Executor userCallbackExecutor = - Context.currentContextExecutor( - transactionOptions.getExecutor() != null - ? transactionOptions.getExecutor() - : firestoreClient.getExecutor()); TransactionRunner transactionRunner = - new TransactionRunner<>( - this, updateFunction, userCallbackExecutor, transactionOptions.getNumberOfAttempts()); + new TransactionRunner<>(this, updateFunction, transactionOptions); return transactionRunner.run(); } diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Transaction.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Transaction.java index c543c3743..6a9fd9714 100644 --- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Transaction.java +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Transaction.java @@ -19,11 +19,13 @@ import com.google.api.core.ApiFunction; import com.google.api.core.ApiFuture; import com.google.api.core.ApiFutures; +import com.google.cloud.firestore.TransactionOptions.TransactionOptionsType; import com.google.common.base.Preconditions; import com.google.common.util.concurrent.MoreExecutors; import com.google.firestore.v1.BeginTransactionRequest; import com.google.firestore.v1.BeginTransactionResponse; import com.google.firestore.v1.RollbackRequest; +import com.google.firestore.v1.TransactionOptions.ReadOnly; import com.google.protobuf.ByteString; import com.google.protobuf.Empty; import java.util.List; @@ -61,12 +63,18 @@ public interface AsyncFunction { ApiFuture updateCallback(Transaction transaction); } + private final TransactionOptions transactionOptions; + @Nullable private final ByteString previousTransactionId; private ByteString transactionId; - private @Nullable ByteString previousTransactionId; - Transaction(FirestoreImpl firestore, @Nullable Transaction previousTransaction) { + Transaction( + FirestoreImpl firestore, + TransactionOptions transactionOptions, + @Nullable Transaction previousTransaction) { super(firestore); - previousTransactionId = previousTransaction != null ? previousTransaction.transactionId : null; + this.transactionOptions = transactionOptions; + this.previousTransactionId = + previousTransaction != null ? previousTransaction.transactionId : null; } Transaction wrapResult(ApiFuture result) { @@ -78,11 +86,18 @@ ApiFuture begin() { BeginTransactionRequest.Builder beginTransaction = BeginTransactionRequest.newBuilder(); beginTransaction.setDatabase(firestore.getDatabaseName()); - if (previousTransactionId != null) { + if (TransactionOptionsType.READ_WRITE.equals(transactionOptions.getType()) + && previousTransactionId != null) { beginTransaction .getOptionsBuilder() .getReadWriteBuilder() .setRetryTransaction(previousTransactionId); + } else if (TransactionOptionsType.READ_ONLY.equals(transactionOptions.getType())) { + final ReadOnly.Builder readOnlyBuilder = ReadOnly.newBuilder(); + if (transactionOptions.getReadTime() != null) { + readOnlyBuilder.setReadTime(transactionOptions.getReadTime()); + } + beginTransaction.getOptionsBuilder().setReadOnly(readOnlyBuilder); } ApiFuture transactionBeginFuture = diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/TransactionOptions.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/TransactionOptions.java index 9b54f7ac8..14dd40819 100644 --- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/TransactionOptions.java +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/TransactionOptions.java @@ -16,42 +16,108 @@ package com.google.cloud.firestore; +import com.google.api.core.InternalExtensionOnly; import com.google.common.base.Preconditions; +import com.google.protobuf.Timestamp; +import com.google.protobuf.TimestampOrBuilder; import java.util.concurrent.Executor; import javax.annotation.Nonnull; import javax.annotation.Nullable; -/** Options specifying the behavior of Firestore Transactions. */ +/** + * Options specifying the behavior of Firestore Transactions. + * + *

A transaction in Firestore can be either read-write or read-only. + * + *

The default set of options is a read-write transaction with a maximum number of 5 attempts. + * This attempt count can be customized via the {@link + * ReadWriteOptionsBuilder#setNumberOfAttempts(int)} method. A new instance of a builder can be + * created by calling {@link #createReadWriteOptionsBuilder()}. + * + *

A read-only transaction can be configured via the {@link ReadOnlyOptionsBuilder} class. A new + * instance can be created by calling {@link #createReadOnlyOptionsBuilder()}. + * + * @see com.google.firestore.v1.TransactionOptions + */ public final class TransactionOptions { + private static final TransactionOptions DEFAULT_READ_WRITE_TRANSACTION_OPTIONS = + createReadWriteOptionsBuilder().build(); + private static final int DEFAULT_NUM_ATTEMPTS = 5; - private final int numberOfAttempts; private final Executor executor; + private final TransactionOptionsType type; + private final int numberOfAttempts; + @Nullable private final Timestamp readTime; - TransactionOptions(int maxAttempts, Executor executor) { - this.numberOfAttempts = maxAttempts; + TransactionOptions( + Executor executor, + TransactionOptionsType type, + int numberOfAttempts, + @Nullable Timestamp readTime) { this.executor = executor; + this.type = type; + this.numberOfAttempts = numberOfAttempts; + this.readTime = readTime; } + /** + * Returns the maximum number of times a transaction will be attempted before resulting in an + * error. + * + * @return The max number of attempts to try and commit the transaction. + */ public int getNumberOfAttempts() { return numberOfAttempts; } + /** @return Executor to be used to run user callbacks on */ @Nullable public Executor getExecutor() { return executor; } /** - * Create a default set of options suitable for most use cases. Transactions will be attempted 5 - * times. + * A type flag indicating the type of transaction represented. + * + * @return The type of transaction this represents. Either read-only or read-write. + */ + @Nonnull + public TransactionOptionsType getType() { + return type; + } + + /** + * A {@link Timestamp} specifying the time documents are to be read at. If null, the server will + * read documents at the most up to date available. If non-null, the specified {@code Timestamp} + * may not be more than 60 seconds in the past (evaluated when the request is processed by the + * server). + * + * @return The specific time to read documents at. A null value means reading the most up to date + * data. + */ + @Nullable + public Timestamp getReadTime() { + // This if statement is not strictly necessary, however is kept here for clarity sake to show + // that readTime is only applicable to a read-only transaction type. + if (TransactionOptionsType.READ_ONLY.equals(type)) { + return readTime; + } else { + return null; + } + } + + /** + * Create a default set of options suitable for most use cases. Transactions will be opened as + * ReadWrite transactions and attempted up to 5 times. * * @return The TransactionOptions object. + * @see #createReadWriteOptionsBuilder() */ @Nonnull public static TransactionOptions create() { - return new TransactionOptions(DEFAULT_NUM_ATTEMPTS, null); + return DEFAULT_READ_WRITE_TRANSACTION_OPTIONS; } /** @@ -59,11 +125,13 @@ public static TransactionOptions create() { * * @param numberOfAttempts The number of execution attempts. * @return The TransactionOptions object. + * @deprecated as of 2.0.0, replaced by {@link ReadWriteOptionsBuilder#setNumberOfAttempts(int)} + * @see #createReadWriteOptionsBuilder() */ @Nonnull + @Deprecated public static TransactionOptions create(int numberOfAttempts) { - Preconditions.checkArgument(numberOfAttempts > 0, "You must allow at least one attempt"); - return new TransactionOptions(numberOfAttempts, null); + return createReadWriteOptionsBuilder().setNumberOfAttempts(numberOfAttempts).build(); } /** @@ -71,10 +139,13 @@ public static TransactionOptions create(int numberOfAttempts) { * * @param executor The executor to run the user callback code on. * @return The TransactionOptions object. + * @deprecated as of 2.0.0, replaced by {@link ReadWriteOptionsBuilder#setExecutor(Executor)} + * @see #createReadWriteOptionsBuilder() */ @Nonnull - public static TransactionOptions create(@Nonnull Executor executor) { - return new TransactionOptions(DEFAULT_NUM_ATTEMPTS, executor); + @Deprecated + public static TransactionOptions create(@Nullable Executor executor) { + return createReadWriteOptionsBuilder().setExecutor(executor).build(); } /** @@ -83,10 +154,166 @@ public static TransactionOptions create(@Nonnull Executor executor) { * @param executor The executor to run the user callback code on. * @param numberOfAttempts The number of execution attempts. * @return The TransactionOptions object. + * @deprecated as of 2.0.0, replaced by {@link ReadWriteOptionsBuilder#setExecutor(Executor)} and + * {@link ReadWriteOptionsBuilder#setNumberOfAttempts(int)} + * @see #createReadWriteOptionsBuilder() */ @Nonnull - public static TransactionOptions create(@Nonnull Executor executor, int numberOfAttempts) { - Preconditions.checkArgument(numberOfAttempts > 0, "You must allow at least one attempt"); - return new TransactionOptions(numberOfAttempts, executor); + @Deprecated + public static TransactionOptions create(@Nullable Executor executor, int numberOfAttempts) { + return createReadWriteOptionsBuilder() + .setExecutor(executor) + .setNumberOfAttempts(numberOfAttempts) + .build(); + } + + /** + * @return a new Builder with default values applicable to configuring options for a read-write + * transaction. + */ + @Nonnull + public static ReadWriteOptionsBuilder createReadWriteOptionsBuilder() { + return new ReadWriteOptionsBuilder(null, DEFAULT_NUM_ATTEMPTS); + } + + /** + * @return a new Builder with default values applicable to configuring options for a read-only + * transaction. + */ + @Nonnull + public static ReadOnlyOptionsBuilder createReadOnlyOptionsBuilder() { + return new ReadOnlyOptionsBuilder(null, null); + } + + @InternalExtensionOnly + public abstract static class Builder> { + @Nullable protected Executor executor; + + protected Builder(@Nullable Executor executor) { + this.executor = executor; + } + + /** + * @return The {@link Executor} user callbacks will execute on, If null, the default executor + * will be used. + */ + @Nullable + public Executor getExecutor() { + return executor; + } + + /** + * @param executor The {@link Executor} user callbacks will executed on. If null, the default + * executor will be used. + * @return {@code this} builder + */ + @Nonnull + @SuppressWarnings("unchecked") + public B setExecutor(@Nullable Executor executor) { + this.executor = executor; + return (B) this; + } + + /** @return an instance of {@link TransactionOptions} from the values passed to this builder */ + @Nonnull + public abstract TransactionOptions build(); + } + + /** + * A typesafe builder class representing those options that are applicable when configuring a + * transaction to be read-only. All methods function as "set" rather than returning a new copy + * with a value set on it. + */ + public static final class ReadOnlyOptionsBuilder extends Builder { + @Nullable private TimestampOrBuilder readTime; + + private ReadOnlyOptionsBuilder(@Nullable Executor executor, @Nullable Timestamp readTime) { + super(executor); + this.readTime = readTime; + } + + /** @return the currently set value that will be used as the readTime. */ + @Nullable + public TimestampOrBuilder getReadTime() { + return readTime; + } + + /** + * Specify to read documents at the given time. This may not be more than 60 seconds in the past + * from when the request is processed by the server. + * + * @param readTime The specific time to read documents at. Must not be older than 60 seconds. A + * null value means read most up to date data. + * @return {@code this} builder + */ + @Nonnull + public ReadOnlyOptionsBuilder setReadTime(@Nullable TimestampOrBuilder readTime) { + this.readTime = readTime; + return this; + } + + @Nonnull + @Override + public TransactionOptions build() { + final Timestamp timestamp; + if (readTime != null && readTime instanceof Timestamp.Builder) { + timestamp = ((Timestamp.Builder) readTime).build(); + } else { + timestamp = (Timestamp) readTime; + } + return new TransactionOptions(executor, TransactionOptionsType.READ_ONLY, 1, timestamp); + } + } + + /** + * A typesafe builder class representing those options that are applicable when configuring a + * transaction to be read-write. All methods function as "set" rather than returning a new copy + * with a value set on it. By default, a read-write transaction will be attempted a max of 5 + * times. + */ + public static final class ReadWriteOptionsBuilder extends Builder { + private int numberOfAttempts; + + private ReadWriteOptionsBuilder(@Nullable Executor executor, int numberOfAttempts) { + super(executor); + this.numberOfAttempts = numberOfAttempts; + } + + /** + * Specify the max number of attempts a transaction will be attempted before resulting in an + * error. + * + * @return The max number of attempts to try and commit the transaction. + */ + public int getNumberOfAttempts() { + return numberOfAttempts; + } + + /** + * Specify the max number of attempts a transaction will be attempted before resulting in an + * error. + * + * @param numberOfAttempts The max number of attempts to try and commit the transaction. + * @return {@code this} builder + * @throws IllegalArgumentException if numberOfAttempts is less than or equal to 0 + */ + @Nonnull + public ReadWriteOptionsBuilder setNumberOfAttempts(int numberOfAttempts) { + Preconditions.checkArgument(numberOfAttempts > 0, "You must allow at least one attempt"); + this.numberOfAttempts = numberOfAttempts; + return this; + } + + @Nonnull + @Override + public TransactionOptions build() { + return new TransactionOptions( + executor, TransactionOptionsType.READ_WRITE, numberOfAttempts, null); + } + } + + public enum TransactionOptionsType { + READ_ONLY, + READ_WRITE } } diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/TransactionRunner.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/TransactionRunner.java index a6724c654..2c6a8c735 100644 --- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/TransactionRunner.java +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/TransactionRunner.java @@ -28,6 +28,7 @@ import com.google.api.gax.rpc.ApiException; import com.google.common.collect.ImmutableMap; import com.google.common.util.concurrent.MoreExecutors; +import io.grpc.Context; import io.opencensus.trace.AttributeValue; import io.opencensus.trace.Span; import io.opencensus.trace.Tracer; @@ -58,10 +59,11 @@ class TransactionRunner { private final Transaction.AsyncFunction userCallback; private final Span span; - private final FirestoreImpl firestoreClient; + private final FirestoreImpl firestore; private final ScheduledExecutorService firestoreExecutor; private final Executor userCallbackExecutor; private final ExponentialRetryAlgorithm backoffAlgorithm; + private final TransactionOptions transactionOptions; private TimedAttemptSettings nextBackoffAttempt; private Transaction transaction; private int attemptsRemaining; @@ -69,20 +71,25 @@ class TransactionRunner { /** * @param firestore The active Firestore instance * @param userCallback The user provided transaction callback - * @param userCallbackExecutor The executor to run the user callback on - * @param numberOfAttempts The total number of attempts for this transaction + * @param transactionOptions The options determining which executor the {@code userCallback} is + * run on and whether the transaction is read-write or read-only */ TransactionRunner( FirestoreImpl firestore, Transaction.AsyncFunction userCallback, - Executor userCallbackExecutor, - int numberOfAttempts) { + TransactionOptions transactionOptions) { + this.transactionOptions = transactionOptions; this.span = tracer.spanBuilder("CloudFirestore.Transaction").startSpan(); - this.firestoreClient = firestore; + this.firestore = firestore; this.firestoreExecutor = firestore.getClient().getExecutor(); this.userCallback = userCallback; - this.attemptsRemaining = numberOfAttempts; - this.userCallbackExecutor = userCallbackExecutor; + this.attemptsRemaining = transactionOptions.getNumberOfAttempts(); + this.userCallbackExecutor = + Context.currentContextExecutor( + transactionOptions.getExecutor() != null + ? transactionOptions.getExecutor() + : this.firestore.getClient().getExecutor()); + this.backoffAlgorithm = new ExponentialRetryAlgorithm( firestore.getOptions().getRetrySettings(), CurrentMillisClock.getDefaultClock()); @@ -90,7 +97,7 @@ class TransactionRunner { } ApiFuture run() { - this.transaction = new Transaction(firestoreClient, this.transaction); + this.transaction = new Transaction(firestore, transactionOptions, this.transaction); --attemptsRemaining; @@ -186,7 +193,7 @@ public ApiFuture apply(T userFunctionResult) { /** The callback that is invoked after the Commit RPC returns. It returns the user result. */ private class CommitTransactionCallback implements ApiFunction, T> { - private T userFunctionResult; + private final T userFunctionResult; CommitTransactionCallback(T userFunctionResult) { this.userFunctionResult = userFunctionResult; diff --git a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/TransactionTest.java b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/TransactionTest.java index ac3767c76..25c60c5f4 100644 --- a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/TransactionTest.java +++ b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/TransactionTest.java @@ -34,12 +34,14 @@ import static com.google.cloud.firestore.LocalFirestoreHelper.rollbackResponse; import static com.google.cloud.firestore.LocalFirestoreHelper.set; import static com.google.cloud.firestore.LocalFirestoreHelper.update; +import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; import com.google.api.core.ApiFuture; import com.google.api.core.ApiFutures; @@ -51,6 +53,9 @@ import com.google.api.gax.rpc.UnaryCallable; import com.google.cloud.Timestamp; import com.google.cloud.firestore.LocalFirestoreHelper.ResponseStubber; +import com.google.cloud.firestore.TransactionOptions.ReadOnlyOptionsBuilder; +import com.google.cloud.firestore.TransactionOptions.ReadWriteOptionsBuilder; +import com.google.cloud.firestore.TransactionOptions.TransactionOptionsType; import com.google.cloud.firestore.spi.v1.FirestoreRpc; import com.google.firestore.v1.BatchGetDocumentsRequest; import com.google.firestore.v1.DocumentMask; @@ -64,6 +69,7 @@ import java.util.List; import java.util.Map; import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; import java.util.concurrent.Executors; import java.util.concurrent.ThreadFactory; import java.util.concurrent.atomic.AtomicInteger; @@ -74,10 +80,10 @@ import org.mockito.ArgumentCaptor; import org.mockito.Captor; import org.mockito.Matchers; -import org.mockito.Mockito; import org.mockito.Spy; import org.mockito.runners.MockitoJUnitRunner; +@SuppressWarnings("deprecation") @RunWith(MockitoJUnitRunner.class) public class TransactionTest { @@ -86,7 +92,7 @@ public class TransactionTest { new ApiException( new Exception("Test exception"), GrpcStatusCode.of(Status.Code.UNKNOWN), true)); - @Spy private FirestoreRpc firestoreRpc = Mockito.mock(FirestoreRpc.class); + @Spy private FirestoreRpc firestoreRpc = mock(FirestoreRpc.class); @Spy private FirestoreImpl firestoreMock = @@ -865,4 +871,79 @@ public String updateCallback(Transaction transaction) { assertEquals(begin(), requests.get(0)); assertEquals(commit(TRANSACTION_ID, writes.toArray(new Write[] {})), requests.get(1)); } + + @Test + public void readOnlyTransactionOptionsBuilder_setReadTime() { + Executor executor = mock(Executor.class); + final com.google.protobuf.Timestamp.Builder readTime = + com.google.protobuf.Timestamp.getDefaultInstance().toBuilder().setSeconds(1).setNanos(0); + final ReadOnlyOptionsBuilder builder = + TransactionOptions.createReadOnlyOptionsBuilder() + .setExecutor(executor) + .setReadTime(readTime); + + final TransactionOptions transactionOptions = builder.build(); + + assertThat(builder.getExecutor()).isSameInstanceAs(executor); + assertThat(builder.getReadTime()).isSameInstanceAs(readTime); + + assertThat(transactionOptions.getExecutor()).isSameInstanceAs(executor); + + assertThat(transactionOptions.getType()).isEqualTo(TransactionOptionsType.READ_ONLY); + assertThat(transactionOptions.getReadTime()).isEqualTo(readTime.build()); + assertThat(transactionOptions.getNumberOfAttempts()).isEqualTo(1); + } + + @Test + public void readOnlyTransactionOptionsBuilder_defaults() { + final ReadOnlyOptionsBuilder builder = TransactionOptions.createReadOnlyOptionsBuilder(); + + final TransactionOptions transactionOptions = builder.build(); + + assertThat(builder.getExecutor()).isNull(); + assertThat(builder.getReadTime()).isNull(); + + assertThat(transactionOptions.getReadTime()).isNull(); + assertThat(transactionOptions.getNumberOfAttempts()).isEqualTo(1); + } + + @Test + public void readWriteTransactionOptionsBuilder_setNumberOfAttempts() { + Executor executor = mock(Executor.class); + final ReadWriteOptionsBuilder builder = + TransactionOptions.createReadWriteOptionsBuilder() + .setExecutor(executor) + .setNumberOfAttempts(2); + + final TransactionOptions transactionOptions = builder.build(); + + assertThat(builder.getExecutor()).isSameInstanceAs(executor); + assertThat(builder.getNumberOfAttempts()).isEqualTo(2); + + assertThat(transactionOptions.getExecutor()).isSameInstanceAs(executor); + + assertThat(transactionOptions.getType()).isEqualTo(TransactionOptionsType.READ_WRITE); + assertThat(transactionOptions.getNumberOfAttempts()).isEqualTo(2); + assertThat(transactionOptions.getReadTime()).isNull(); + } + + @Test + public void readWriteTransactionOptionsBuilder_defaults() { + final TransactionOptions transactionOptions = + TransactionOptions.createReadWriteOptionsBuilder().build(); + + assertThat(transactionOptions.getExecutor()).isNull(); + assertThat(transactionOptions.getNumberOfAttempts()).isEqualTo(5); + assertThat(transactionOptions.getReadTime()).isNull(); + } + + @Test + public void readWriteTransactionOptionsBuilder_errorAttemptingToSetNumAttemptsLessThanOne() { + try { + TransactionOptions.createReadWriteOptionsBuilder().setNumberOfAttempts(0); + fail("Error expected"); + } catch (IllegalArgumentException ignore) { + // expected + } + } } diff --git a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITSystemTest.java b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITSystemTest.java index d2a768181..1a1a3d4d1 100644 --- a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITSystemTest.java +++ b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITSystemTest.java @@ -18,6 +18,7 @@ import static com.google.cloud.firestore.LocalFirestoreHelper.UPDATE_SINGLE_FIELD_OBJECT; import static com.google.cloud.firestore.LocalFirestoreHelper.map; +import static com.google.common.truth.Truth.assertThat; import static java.util.Arrays.asList; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; @@ -56,12 +57,16 @@ import com.google.cloud.firestore.SetOptions; import com.google.cloud.firestore.Transaction; import com.google.cloud.firestore.Transaction.Function; +import com.google.cloud.firestore.TransactionOptions; import com.google.cloud.firestore.WriteBatch; import com.google.cloud.firestore.WriteResult; import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.firestore.v1.RunQueryRequest; +import io.grpc.Status; +import io.grpc.Status.Code; +import io.grpc.StatusRuntimeException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -72,8 +77,12 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import javax.annotation.Nullable; +import org.apache.commons.lang3.exception.ExceptionUtils; import org.junit.After; import org.junit.Before; import org.junit.Rule; @@ -1414,6 +1423,93 @@ public void bulkWriterWritesInOrder() throws Exception { assertEquals(Collections.singletonMap("foo", "bar3"), result.get().getData()); } + @Test + public void readOnlyTransaction_successfulGet() + throws ExecutionException, InterruptedException, TimeoutException { + final DocumentReference documentReference = randomColl.add(SINGLE_FIELD_MAP).get(); + + final AtomicReference ref = new AtomicReference<>(); + + final ApiFuture runTransaction = + firestore.runTransaction( + new Function() { + @Override + public Void updateCallback(Transaction transaction) throws Exception { + final DocumentSnapshot snapshot = + transaction.get(documentReference).get(5, TimeUnit.SECONDS); + ref.compareAndSet(null, snapshot); + return null; + } + }, + TransactionOptions.createReadOnlyOptionsBuilder().build()); + + runTransaction.get(10, TimeUnit.SECONDS); + assertEquals("bar", ref.get().get("foo")); + } + + @Test + public void readOnlyTransaction_failureWhenAttemptingWrite() + throws InterruptedException, TimeoutException { + + final DocumentReference documentReference = randomColl.document("tx/ro/writeShouldFail"); + final ApiFuture runTransaction = + firestore.runTransaction( + new Function() { + @Override + public Void updateCallback(Transaction transaction) { + transaction.set(documentReference, SINGLE_FIELD_MAP); + return null; + } + }, + TransactionOptions.createReadOnlyOptionsBuilder().build()); + + try { + runTransaction.get(10, TimeUnit.SECONDS); + } catch (ExecutionException e) { + final Throwable cause = e.getCause(); + assertThat(cause).isInstanceOf(FirestoreException.class); + final Throwable rootCause = ExceptionUtils.getRootCause(cause); + assertThat(rootCause).isInstanceOf(StatusRuntimeException.class); + final StatusRuntimeException invalidArgument = (StatusRuntimeException) rootCause; + final Status status = invalidArgument.getStatus(); + assertThat(status.getCode()).isEqualTo(Code.INVALID_ARGUMENT); + assertThat(status.getDescription()).contains("read-only"); + } + } + + @Test + public void readOnlyTransaction_failureWhenAttemptReadOlderThan60Seconds() + throws ExecutionException, InterruptedException, TimeoutException { + final DocumentReference documentReference = randomColl.add(SINGLE_FIELD_MAP).get(); + + final TransactionOptions options = + TransactionOptions.createReadOnlyOptionsBuilder() + .setReadTime(com.google.protobuf.Timestamp.newBuilder().setSeconds(1).setNanos(0)) + .build(); + + final ApiFuture runTransaction = + firestore.runTransaction( + new Function() { + @Override + public Void updateCallback(Transaction transaction) throws Exception { + transaction.get(documentReference).get(5, TimeUnit.SECONDS); + return null; + } + }, + options); + + try { + runTransaction.get(10, TimeUnit.SECONDS); + } catch (ExecutionException e) { + final Throwable rootCause = ExceptionUtils.getRootCause(e); + assertThat(rootCause).isInstanceOf(StatusRuntimeException.class); + final StatusRuntimeException invalidArgument = (StatusRuntimeException) rootCause; + final Status status = invalidArgument.getStatus(); + assertThat(status.getCode()).isEqualTo(Code.FAILED_PRECONDITION); + assertThat(status.getDescription()).contains("old"); + } + } + /** Wrapper around ApiStreamObserver that returns the results in a list. */ private static class StreamConsumer implements ApiStreamObserver { SettableApiFuture> done = SettableApiFuture.create();