diff --git a/firebase-firestore/CHANGELOG.md b/firebase-firestore/CHANGELOG.md index e75953b1355..6bd04cf6f8a 100644 --- a/firebase-firestore/CHANGELOG.md +++ b/firebase-firestore/CHANGELOG.md @@ -3,6 +3,8 @@ by opting into a release at [go/firebase-android-release](http:go/firebase-android-release) (Googlers only). # Unreleased +- [changed] Added `TransactionOptions` to control how many times a transaction + will retry commits before failing. - [fixed] Fixed an issue where patching multiple fields shadows each other (#3528). # 24.1.1 diff --git a/firebase-firestore/api.txt b/firebase-firestore/api.txt index 8d66447e0e9..ab8b3f4192c 100644 --- a/firebase-firestore/api.txt +++ b/firebase-firestore/api.txt @@ -152,6 +152,7 @@ package com.google.firebase.firestore { method @NonNull public com.google.firebase.firestore.LoadBundleTask loadBundle(@NonNull java.nio.ByteBuffer); method @NonNull public com.google.android.gms.tasks.Task runBatch(@NonNull com.google.firebase.firestore.WriteBatch.Function); method @NonNull public com.google.android.gms.tasks.Task runTransaction(@NonNull com.google.firebase.firestore.Transaction.Function); + method @NonNull public com.google.android.gms.tasks.Task runTransaction(@NonNull com.google.firebase.firestore.TransactionOptions, @NonNull com.google.firebase.firestore.Transaction.Function); method public void setFirestoreSettings(@NonNull com.google.firebase.firestore.FirebaseFirestoreSettings); method @NonNull public com.google.android.gms.tasks.Task setIndexConfiguration(@NonNull String); method public static void setLoggingEnabled(boolean); @@ -387,6 +388,17 @@ package com.google.firebase.firestore { method @Nullable public TResult apply(@NonNull com.google.firebase.firestore.Transaction) throws com.google.firebase.firestore.FirebaseFirestoreException; } + public final class TransactionOptions { + method public int getMaxAttempts(); + } + + public static final class TransactionOptions.Builder { + ctor public TransactionOptions.Builder(); + ctor public TransactionOptions.Builder(@NonNull com.google.firebase.firestore.TransactionOptions); + method @NonNull public com.google.firebase.firestore.TransactionOptions build(); + method @NonNull public com.google.firebase.firestore.TransactionOptions.Builder setMaxAttempts(int); + } + public class WriteBatch { method @NonNull public com.google.android.gms.tasks.Task commit(); method @NonNull public com.google.firebase.firestore.WriteBatch delete(@NonNull com.google.firebase.firestore.DocumentReference); diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/TransactionTest.java b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/TransactionTest.java index 23c74406068..36f35495508 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/TransactionTest.java +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/TransactionTest.java @@ -21,6 +21,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -29,7 +30,6 @@ import com.google.android.gms.tasks.TaskCompletionSource; import com.google.android.gms.tasks.Tasks; import com.google.firebase.firestore.FirebaseFirestoreException.Code; -import com.google.firebase.firestore.core.TransactionRunner; import com.google.firebase.firestore.testutil.IntegrationTestUtil; import com.google.firebase.firestore.util.AsyncQueue.TimerId; import java.util.ArrayList; @@ -651,7 +651,38 @@ public void testMakesDefaultMaxAttempts() { Exception e = waitForException(transactionTask); assertEquals(Code.FAILED_PRECONDITION, ((FirebaseFirestoreException) e).getCode()); - assertEquals(TransactionRunner.DEFAULT_MAX_ATTEMPTS_COUNT, count.get()); + assertEquals(TransactionOptions.DEFAULT_MAX_ATTEMPTS_COUNT, count.get()); + } + + @Test + public void testMakesOptionSpecifiedMaxAttempts() { + TransactionOptions options = new TransactionOptions.Builder().setMaxAttempts(1).build(); + + FirebaseFirestore firestore = testFirestore(); + DocumentReference doc1 = firestore.collection("counters").document(); + AtomicInteger count = new AtomicInteger(0); + waitFor(doc1.set(map("count", 15))); + Task transactionTask = + firestore.runTransaction( + options, + transaction -> { + // Get the first doc. + transaction.get(doc1); + // Do a write outside of the transaction to cause the transaction to fail. + waitFor(doc1.set(map("count", 1234 + count.incrementAndGet()))); + return null; + }); + + Exception e = waitForException(transactionTask); + assertEquals(Code.FAILED_PRECONDITION, ((FirebaseFirestoreException) e).getCode()); + assertEquals(options.getMaxAttempts(), count.get()); + } + + @Test + public void testTransactionOptionsZeroMaxAttempts_shouldThrowIllegalArgumentException() { + assertThrows( + IllegalArgumentException.class, + () -> new TransactionOptions.Builder().setMaxAttempts(0).build()); } @Test diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/FirebaseFirestore.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/FirebaseFirestore.java index 510bb9402d6..44609ab6d9e 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/FirebaseFirestore.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/FirebaseFirestore.java @@ -405,9 +405,10 @@ public Query collectionGroup(@NonNull String collectionId) { } /** - * Executes the given updateFunction and then attempts to commit the changes applied within the - * transaction. If any document read within the transaction has changed, the updateFunction will - * be retried. If it fails to commit after 5 attempts, the transaction will fail. + * Executes the given {@code updateFunction} and then attempts to commit the changes applied + * within the transaction. If any document read within the transaction has changed, the + * updateFunction will be retried. If it fails to commit after 5 attempts (the default failure + * limit), the transaction will fail. * *

The maximum number of writes allowed in a single transaction is 500, but note that each * usage of {@link FieldValue#serverTimestamp()}, {@link FieldValue#arrayUnion(Object...)}, {@link @@ -419,7 +420,7 @@ public Query collectionGroup(@NonNull String collectionId) { * @return The task returned from the updateFunction. */ private Task runTransaction( - Transaction.Function updateFunction, Executor executor) { + TransactionOptions options, Transaction.Function updateFunction, Executor executor) { ensureClientConfigured(); // We wrap the function they provide in order to @@ -434,13 +435,15 @@ private Task runTransaction( updateFunction.apply( new Transaction(internalTransaction, FirebaseFirestore.this))); - return client.transaction(wrappedUpdateFunction); + return client.transaction(options, wrappedUpdateFunction); } /** - * Executes the given updateFunction and then attempts to commit the changes applied within the - * transaction. If any document read within the transaction has changed, the updateFunction will - * be retried. If it fails to commit after 5 attempts, the transaction will fail. + * Executes the given {@code updateFunction} and then attempts to commit the changes applied + * within the transaction. If any document read within the transaction has changed, the + * updateFunction will be retried. If it fails to commit after 5 attempts (the default failure + * limit), the transaction will fail. To have a different number of retries, use the {@link + * FirebaseFirestore#runTransaction(TransactionOptions, Transaction.Function)} method instead. * * @param updateFunction The function to execute within the transaction context. * @return The task returned from the updateFunction. @@ -448,9 +451,27 @@ private Task runTransaction( @NonNull public Task runTransaction( @NonNull Transaction.Function updateFunction) { + return runTransaction(TransactionOptions.DEFAULT, updateFunction); + } + + /** + * Executes the given {@code updateFunction} and then attempts to commit the changes applied + * within the transaction. If any document read within the transaction has changed, the + * updateFunction will be retried. If it fails to commit after the maxmimum number of attempts + * specified in transactionOptions, the transaction will fail. + * + * @param options The transaction options for controlling execution. + * @param updateFunction The function to execute within the transaction context. + * @return The task returned from the updateFunction. + */ + @NonNull + public Task runTransaction( + @NonNull TransactionOptions options, @NonNull Transaction.Function updateFunction) { checkNotNull(updateFunction, "Provided transaction update function must not be null."); return runTransaction( - updateFunction, com.google.firebase.firestore.core.Transaction.getDefaultExecutor()); + options, + updateFunction, + com.google.firebase.firestore.core.Transaction.getDefaultExecutor()); } /** diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/TransactionOptions.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/TransactionOptions.java new file mode 100644 index 00000000000..5e649105183 --- /dev/null +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/TransactionOptions.java @@ -0,0 +1,103 @@ +// Copyright 2022 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.firebase.firestore; + +import androidx.annotation.NonNull; + +/** + * Options to customize transaction behavior for {@link + * FirebaseFirestore#runTransaction(TransactionOptions, Transaction.Function)}. + */ +public final class TransactionOptions { + + static final TransactionOptions DEFAULT = new TransactionOptions.Builder().build(); + static final int DEFAULT_MAX_ATTEMPTS_COUNT = 5; + + private final int maxAttempts; + + private TransactionOptions(int maxAttempts) { + this.maxAttempts = maxAttempts; + } + + /** A Builder for creating {@code TransactionOptions}. */ + public static final class Builder { + private int maxAttempts = DEFAULT_MAX_ATTEMPTS_COUNT; + + /** Constructs a new {@code TransactionOptions} Builder object. */ + public Builder() {} + + /** + * Constructs a new {@code TransactionOptions} Builder based on an existing {@code + * TransactionOptions} object. + */ + public Builder(@NonNull TransactionOptions options) { + maxAttempts = options.maxAttempts; + } + + /** + * Set maximum number of attempts to commit, after which transaction fails. + * + *

The default value is 5. Setting the value to less than 1 will result in an {@link + * IllegalArgumentException}. + * + * @return This builder + */ + @NonNull + public Builder setMaxAttempts(int maxAttempts) { + if (maxAttempts < 1) throw new IllegalArgumentException("Max attempts must be at least 1"); + this.maxAttempts = maxAttempts; + return this; + } + + /** + * Build the {@code TransactionOptions} object. + * + * @return The built {@code TransactionOptions} object + */ + @NonNull + public TransactionOptions build() { + return new TransactionOptions(maxAttempts); + } + } + + /** + * Get maximum number of attempts to commit, after which transaction fails. Default is 5. + * + * @return The maximum number of attempts + */ + public int getMaxAttempts() { + return maxAttempts; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + TransactionOptions that = (TransactionOptions) o; + + return maxAttempts == that.maxAttempts; + } + + @Override + public int hashCode() { + return maxAttempts; + } + + @Override + public String toString() { + return "TransactionOptions{" + "maxAttempts=" + maxAttempts + '}'; + } +} diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/core/FirestoreClient.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/FirestoreClient.java index 5554d4f0b43..464cd5913f9 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/core/FirestoreClient.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/FirestoreClient.java @@ -26,6 +26,7 @@ import com.google.firebase.firestore.FirebaseFirestoreException.Code; import com.google.firebase.firestore.FirebaseFirestoreSettings; import com.google.firebase.firestore.LoadBundleTask; +import com.google.firebase.firestore.TransactionOptions; import com.google.firebase.firestore.auth.CredentialsProvider; import com.google.firebase.firestore.auth.User; import com.google.firebase.firestore.bundle.BundleReader; @@ -228,10 +229,12 @@ public Task write(final List mutations) { } /** Tries to execute the transaction in updateFunction. */ - public Task transaction(Function> updateFunction) { + public Task transaction( + TransactionOptions options, Function> updateFunction) { this.verifyNotTerminated(); return AsyncQueue.callTask( - asyncQueue.getExecutor(), () -> syncEngine.transaction(asyncQueue, updateFunction)); + asyncQueue.getExecutor(), + () -> syncEngine.transaction(asyncQueue, options, updateFunction)); } /** diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/core/SyncEngine.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/SyncEngine.java index f51d1e88062..452d7907c76 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/core/SyncEngine.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/SyncEngine.java @@ -26,6 +26,7 @@ import com.google.firebase.firestore.FirebaseFirestoreException; import com.google.firebase.firestore.LoadBundleTask; import com.google.firebase.firestore.LoadBundleTaskProgress; +import com.google.firebase.firestore.TransactionOptions; import com.google.firebase.firestore.auth.User; import com.google.firebase.firestore.bundle.BundleElement; import com.google.firebase.firestore.bundle.BundleLoader; @@ -307,8 +308,10 @@ private void addUserCallback(int batchId, TaskCompletionSource userTask) { *

The Task returned is resolved when the transaction is fully committed. */ public Task transaction( - AsyncQueue asyncQueue, Function> updateFunction) { - return new TransactionRunner(asyncQueue, remoteStore, updateFunction).run(); + AsyncQueue asyncQueue, + TransactionOptions options, + Function> updateFunction) { + return new TransactionRunner(asyncQueue, remoteStore, options, updateFunction).run(); } /** Called by FirestoreClient to notify us of a new remote event. */ diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/core/TransactionRunner.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/TransactionRunner.java index 7dd50b36ca4..70a249cc804 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/core/TransactionRunner.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/TransactionRunner.java @@ -18,6 +18,7 @@ import com.google.android.gms.tasks.Task; import com.google.android.gms.tasks.TaskCompletionSource; import com.google.firebase.firestore.FirebaseFirestoreException; +import com.google.firebase.firestore.TransactionOptions; import com.google.firebase.firestore.remote.Datastore; import com.google.firebase.firestore.remote.RemoteStore; import com.google.firebase.firestore.util.AsyncQueue; @@ -27,7 +28,6 @@ /** TransactionRunner encapsulates the logic needed to run and retry transactions with backoff. */ public class TransactionRunner { - public static final int DEFAULT_MAX_ATTEMPTS_COUNT = 5; private AsyncQueue asyncQueue; private RemoteStore remoteStore; private Function> updateFunction; @@ -39,12 +39,13 @@ public class TransactionRunner { public TransactionRunner( AsyncQueue asyncQueue, RemoteStore remoteStore, + TransactionOptions options, Function> updateFunction) { this.asyncQueue = asyncQueue; this.remoteStore = remoteStore; this.updateFunction = updateFunction; - this.attemptsRemaining = DEFAULT_MAX_ATTEMPTS_COUNT; + this.attemptsRemaining = options.getMaxAttempts(); backoff = new ExponentialBackoff(asyncQueue, TimerId.RETRY_TRANSACTION); }