From 45781bc2bcc8e755ac76f35810da5d73b5f18c47 Mon Sep 17 00:00:00 2001 From: Denver Coneybeare Date: Wed, 6 Jul 2022 23:56:23 -0400 Subject: [PATCH] Firestore TransactionOptions added, to specify maxAttempts (#318) --- docs/readme.md | 3 + firestore/CMakeLists.txt | 1 + firestore/src/FirebaseFirestore.cs | 64 +++++++++++++-- firestore/src/TransactionManager.cs | 6 +- firestore/src/TransactionOptions.cs | 82 +++++++++++++++++++ firestore/src/swig/firestore.i | 7 ++ firestore/src/swig/transaction_manager.cc | 25 +++--- firestore/src/swig/transaction_manager.h | 1 + .../Sample/Firestore/InvalidArgumentsTest.cs | 38 +++++++++ .../Sample/Firestore/UIHandlerAutomated.cs | 65 ++++++++++++++- 10 files changed, 272 insertions(+), 20 deletions(-) create mode 100644 firestore/src/TransactionOptions.cs diff --git a/docs/readme.md b/docs/readme.md index 4a462d48..493a999c 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -158,6 +158,9 @@ Release Notes ### 9.2.0 - Changes - Crashlytics: Fix requiring user code to reference Crashlytics when using il2cpp. + - Firestore: Added `TransactionOptions` to control how many times a + transaction will retry commits before failing + ([#318](https://github.com/firebase/firebase-unity-sdk/pull/318)). ### 9.1.0 - Changes diff --git a/firestore/CMakeLists.txt b/firestore/CMakeLists.txt index 170d4c35..bbd41830 100644 --- a/firestore/CMakeLists.txt +++ b/firestore/CMakeLists.txt @@ -78,6 +78,7 @@ set(firebase_firestore_src src/Source.cs src/Timestamp.cs src/Transaction.cs + src/TransactionOptions.cs src/TransactionManager.cs src/UnknownPropertyHandling.cs src/ValueDeserializer.cs diff --git a/firestore/src/FirebaseFirestore.cs b/firestore/src/FirebaseFirestore.cs index 95c23604..9e28611d 100644 --- a/firestore/src/FirebaseFirestore.cs +++ b/firestore/src/FirebaseFirestore.cs @@ -248,10 +248,7 @@ public WriteBatch StartBatch() { /// be invoked on the main thread. /// A task which completes when the transaction has committed. public Task RunTransactionAsync(Func callback) { - Preconditions.CheckNotNull(callback, nameof(callback)); - // Just pass through to the overload where the callback returns a Task. - return RunTransactionAsync(transaction => - Util.MapResult(callback(transaction), null)); + return RunTransactionAsync(new TransactionOptions(), callback); } /// @@ -276,8 +273,65 @@ public Task RunTransactionAsync(Func callback) { /// A task which completes when the transaction has committed. The result of the task /// then contains the result of the callback. public Task RunTransactionAsync(Func> callback) { + return RunTransactionAsync(new TransactionOptions(), callback); + } + + /// + /// Runs a transaction asynchronously, with an asynchronous callback that returns a value. + /// The specified callback is executed for a newly-created transaction. + /// + /// + /// RunTransactionAsync executes the given callback on the main thread and then + /// attempts to commit the changes applied within the transaction. If any document read within + /// the transaction has changed, the will be retried. If it fails to + /// commit after the maximum number of attempts specified in the given TransactionOptions + /// object, the transaction will fail. + /// + /// The maximum number of writes allowed in a single transaction is 500, but note that + /// each usage of , FieldValue.ArrayUnion, + /// FieldValue.ArrayRemove, or FieldValue.Increment inside a transaction counts as + /// an additional write. + /// + /// + /// The result type of the callback. + /// The transaction options for controlling execution. Must not be + /// null. + /// The callback to execute. Must not be null. The callback will + /// be invoked on the main thread. + /// A task which completes when the transaction has committed. The result of the task + /// then contains the result of the callback. + public Task RunTransactionAsync(TransactionOptions options, Func> callback) { + Preconditions.CheckNotNull(options, nameof(options)); + Preconditions.CheckNotNull(callback, nameof(callback)); + return WithFirestoreProxy(proxy => _transactionManager.RunTransactionAsync(options, callback)); + } + + /// + /// Runs a transaction asynchronously, with an asynchronous callback that doesn't return a + /// value. The specified callback is executed for a newly-created transaction. + /// + /// + /// RunTransactionAsync executes the given callback on the main thread and then + /// attempts to commit the changes applied within the transaction. If any document read within + /// the transaction has changed, the will be retried. If it fails to + /// commit after the maximum number of attempts specified in the given TransactionOptions + /// object, the transaction will fail. + /// + /// The maximum number of writes allowed in a single transaction is 500, but note that + /// each usage of , FieldValue.ArrayUnion, + /// FieldValue.ArrayRemove, or FieldValue.Increment inside a transaction counts as + /// an additional write. + /// + /// The transaction options for controlling execution. Must not be + /// null. + /// The callback to execute. Must not be null. The callback will + /// be invoked on the main thread. + /// A task which completes when the transaction has committed. + public Task RunTransactionAsync(TransactionOptions options, Func callback) { + Preconditions.CheckNotNull(options, nameof(options)); Preconditions.CheckNotNull(callback, nameof(callback)); - return WithFirestoreProxy(proxy => _transactionManager.RunTransactionAsync(callback)); + // Just pass through to the overload where the callback returns a Task. + return RunTransactionAsync(options, transaction => Util.MapResult(callback(transaction), null)); } private static SnapshotsInSyncCallbackMap snapshotsInSyncCallbacks = new SnapshotsInSyncCallbackMap(); diff --git a/firestore/src/TransactionManager.cs b/firestore/src/TransactionManager.cs index 6d8a8bd9..c95aabb3 100644 --- a/firestore/src/TransactionManager.cs +++ b/firestore/src/TransactionManager.cs @@ -67,9 +67,11 @@ public void Dispose() { /// /// Runs a transaction. /// + /// The transaction options to use.. /// The callback to run.. /// A task that completes when the transaction has completed. - internal Task RunTransactionAsync(Func> callback) { + internal Task RunTransactionAsync(TransactionOptions options, + Func> callback) { // Store the result of the most recent invocation of the user-supplied callback. bool callbackWrapperInvoked = false; Task lastCallbackTask = null; @@ -118,7 +120,7 @@ internal Task RunTransactionAsync(Func> callback) { } }; - return _transactionManagerProxy.RunTransactionAsync(callbackId, ExecuteCallback) + return _transactionManagerProxy.RunTransactionAsync(callbackId, options.Proxy, ExecuteCallback) .ContinueWith(overallCallback); } diff --git a/firestore/src/TransactionOptions.cs b/firestore/src/TransactionOptions.cs new file mode 100644 index 00000000..cba33d44 --- /dev/null +++ b/firestore/src/TransactionOptions.cs @@ -0,0 +1,82 @@ +// 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 +// +// https://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. + +using System; +using System.Threading; + +namespace Firebase.Firestore { + +/// +/// Options to customize transaction behavior for +/// . +/// +public sealed class TransactionOptions { + + // The lock that must be held during all accesses to _proxy. + private readonly ReaderWriterLock _proxyLock = new ReaderWriterLock(); + + // The underlying C++ TransactionOptions object. + private TransactionOptionsProxy _proxy = new TransactionOptionsProxy(); + + internal TransactionOptionsProxy Proxy { + get { + _proxyLock.AcquireReaderLock(Int32.MaxValue); + try { + return new TransactionOptionsProxy(_proxy); + } finally { + _proxyLock.ReleaseReaderLock(); + } + } + } + + /// + /// Creates the default TransactionOptions. + /// + public TransactionOptions() { + } + + /// + /// The maximum number of attempts to commit, after which the transaction fails. + /// + /// + /// + /// The default value is 5, and must be greater than zero. + /// + public Int32 MaxAttempts { + get { + _proxyLock.AcquireReaderLock(Int32.MaxValue); + try { + return _proxy.max_attempts(); + } finally { + _proxyLock.ReleaseReaderLock(); + } + } + set { + _proxyLock.AcquireWriterLock(Int32.MaxValue); + try { + _proxy.set_max_attempts(value); + } finally { + _proxyLock.ReleaseWriterLock(); + } + } + } + + /// + public override string ToString() { + return nameof(TransactionOptions) + "{" + nameof(MaxAttempts) + "=" + MaxAttempts + "}"; + } + +} + +} diff --git a/firestore/src/swig/firestore.i b/firestore/src/swig/firestore.i index 60e3b729..853a1091 100644 --- a/firestore/src/swig/firestore.i +++ b/firestore/src/swig/firestore.i @@ -411,6 +411,13 @@ SWIG_CREATE_PROXY(firebase::firestore::LoadBundleTaskProgress); %rename("%s") firebase::firestore::LoadBundleTaskProgress::state; %include "firestore/src/include/firebase/firestore/load_bundle_task_progress.h" +// Generate a C# wrapper for TransactionOptions. +SWIG_CREATE_PROXY(firebase::firestore::TransactionOptions); +%rename("%s") firebase::firestore::TransactionOptions::TransactionOptions; +%rename("%s") firebase::firestore::TransactionOptions::max_attempts; +%rename("%s") firebase::firestore::TransactionOptions::set_max_attempts; +%include "firestore/src/include/firebase/firestore/transaction_options.h" + // Generate a C# wrapper for Firestore. Comes last because it refers to multiple // other classes (e.g. `CollectionReference`). SWIG_CREATE_PROXY(firebase::firestore::Firestore); diff --git a/firestore/src/swig/transaction_manager.cc b/firestore/src/swig/transaction_manager.cc index 72c0391a..9d382b10 100644 --- a/firestore/src/swig/transaction_manager.cc +++ b/firestore/src/swig/transaction_manager.cc @@ -177,6 +177,7 @@ class TransactionManagerInternal } Future RunTransaction(int32_t callback_id, + TransactionOptions options, TransactionCallbackFn callback_fn) { std::lock_guard lock(mutex_); if (is_disposed_) { @@ -184,16 +185,17 @@ class TransactionManagerInternal } auto shared_this = shared_from_this(); - return firestore_->RunTransaction([shared_this, callback_id, callback_fn]( - Transaction& transaction, - std::string& error_message) { - if (shared_this->ExecuteCallback(callback_id, callback_fn, transaction)) { - return Error::kErrorOk; - } else { - // Return a non-retryable error code. - return Error::kErrorInvalidArgument; + return firestore_->RunTransaction( + options, + [shared_this, callback_id, callback_fn](Transaction& transaction, std::string& error_message) { + if (shared_this->ExecuteCallback(callback_id, callback_fn, transaction)) { + return Error::kErrorOk; + } else { + // Return a non-retryable error code. + return Error::kErrorInvalidArgument; + } } - }); + ); } private: @@ -271,14 +273,15 @@ void TransactionManager::Dispose() { } Future TransactionManager::RunTransaction( - int32_t callback_id, TransactionCallbackFn callback_fn) { + int32_t callback_id, TransactionOptions options, + TransactionCallbackFn callback_fn) { // Make a local copy of `internal_` since it could be reset asynchronously // by a call to `Dispose()`. std::shared_ptr internal_local = internal_; if (!internal_local) { return {}; } - return internal_local->RunTransaction(callback_id, callback_fn); + return internal_local->RunTransaction(callback_id, options, callback_fn); } void TransactionCallback::OnCompletion(bool callback_successful) { diff --git a/firestore/src/swig/transaction_manager.h b/firestore/src/swig/transaction_manager.h index 706adc49..9e5da963 100644 --- a/firestore/src/swig/transaction_manager.h +++ b/firestore/src/swig/transaction_manager.h @@ -198,6 +198,7 @@ class TransactionManager { // If `Dispose()` has been invoked, or the `Firestore` instance has been // destroyed, then this method will immediately return an invalid `Future`. Future RunTransaction(int32_t callback_id, + TransactionOptions options, TransactionCallbackFn callback); private: diff --git a/firestore/testapp/Assets/Firebase/Sample/Firestore/InvalidArgumentsTest.cs b/firestore/testapp/Assets/Firebase/Sample/Firestore/InvalidArgumentsTest.cs index c85ecacd..26ac166a 100644 --- a/firestore/testapp/Assets/Firebase/Sample/Firestore/InvalidArgumentsTest.cs +++ b/firestore/testapp/Assets/Firebase/Sample/Firestore/InvalidArgumentsTest.cs @@ -171,6 +171,14 @@ public static InvalidArgumentsTestCase[] TestCases { name = "FirebaseFirestore_RunTransactionAsync_WithTypeParameter_NullCallback", action = FirebaseFirestore_RunTransactionAsync_WithTypeParameter_NullCallback }, + new InvalidArgumentsTestCase { + name = "FirebaseFirestore_RunTransactionAsync_WithoutTypeParameter_WithOptions_NullCallback", + action = FirebaseFirestore_RunTransactionAsync_WithoutTypeParameter_WithOptions_NullCallback + }, + new InvalidArgumentsTestCase { + name = "FirebaseFirestore_RunTransactionAsync_WithTypeParameter_WithOptions_NullCallback", + action = FirebaseFirestore_RunTransactionAsync_WithTypeParameter_WithOptions_NullCallback + }, new InvalidArgumentsTestCase { name = "FirebaseFirestoreSettings_Host_Null", action = FirebaseFirestoreSettings_Host_Null }, new InvalidArgumentsTestCase { name = "FirebaseFirestoreSettings_Host_EmptyString", @@ -693,6 +701,36 @@ private static void FirebaseFirestore_RunTransactionAsync_WithTypeParameter_Null () => handler.db.RunTransactionAsync(null)); } + private static void FirebaseFirestore_RunTransactionAsync_WithoutTypeParameter_WithOptions_NullCallback( + UIHandlerAutomated handler) { + var options = new TransactionOptions(); + handler.AssertException(typeof(ArgumentNullException), + () => handler.db.RunTransactionAsync(options, null)); + } + + private static void FirebaseFirestore_RunTransactionAsync_WithTypeParameter_WithOptions_NullCallback( + UIHandlerAutomated handler) { + var options = new TransactionOptions(); + handler.AssertException(typeof(ArgumentNullException), + () => handler.db.RunTransactionAsync(options, null)); + } + + private static void FirebaseFirestore_RunTransactionAsync_WithoutTypeParameter_WithOptions_NullOptions( + UIHandlerAutomated handler) { + DocumentReference doc = handler.TestDocument(); + handler.AssertException(typeof(ArgumentNullException), + () => handler.db.RunTransactionAsync(null, tx => tx.GetSnapshotAsync(doc))); + } + + private static void FirebaseFirestore_RunTransactionAsync_WithTypeParameter_WithOptions_NullOptions( + UIHandlerAutomated handler) { + DocumentReference doc = handler.TestDocument(); + handler.AssertException(typeof(ArgumentNullException), + () => handler.db.RunTransactionAsync(null, tx => tx.GetSnapshotAsync(doc) + .ContinueWith(snapshot => new object())) + ); + } + private static void FirebaseFirestoreSettings_Host_Null(UIHandlerAutomated handler) { FirebaseFirestoreSettings settings = handler.db.Settings; handler.AssertException(typeof(ArgumentNullException), () => settings.Host = null); diff --git a/firestore/testapp/Assets/Firebase/Sample/Firestore/UIHandlerAutomated.cs b/firestore/testapp/Assets/Firebase/Sample/Firestore/UIHandlerAutomated.cs index 9ebd579d..c94a54b6 100644 --- a/firestore/testapp/Assets/Firebase/Sample/Firestore/UIHandlerAutomated.cs +++ b/firestore/testapp/Assets/Firebase/Sample/Firestore/UIHandlerAutomated.cs @@ -105,6 +105,8 @@ protected override void Start() { TestTransactionsInParallel, // Waiting for all retries is slow, so we usually leave this test disabled. // TestTransactionMaxRetryFailure, + TestTransactionOptions, + TestTransactionWithExplicitMaxAttempts, TestSetOptions, TestCanTraverseCollectionsAndDocuments, TestCanTraverseCollectionAndDocumentParents, @@ -1096,6 +1098,43 @@ Task TestTransaction() { }); } + Task TestTransactionOptions() { + return Async(() => { + // Verify the initial values of a newly-created TransactionOptions object. + { + var options = new TransactionOptions(); + AssertEq(options.MaxAttempts, 5); + } + + // Verify that setting TransactionOptions.MaxAttempts works. + { + var options = new TransactionOptions(); + options.MaxAttempts = 1; + AssertEq(options.MaxAttempts, 1); + options.MaxAttempts = 42; + AssertEq(options.MaxAttempts, 42); + options.MaxAttempts = Int32.MaxValue; + AssertEq(options.MaxAttempts, Int32.MaxValue); + } + + // Verify that setting TransactionOptions.MaxAttempts throws on invalid value. + { + var options = new TransactionOptions(); + AssertException(typeof(ArgumentException), () => { options.MaxAttempts = 0; }); + AssertException(typeof(ArgumentException), () => { options.MaxAttempts = -1; }); + AssertException(typeof(ArgumentException), () => { options.MaxAttempts = -42; }); + AssertException(typeof(ArgumentException), () => { options.MaxAttempts = Int32.MinValue; }); + } + + // Verify that TransactionOptions.ToString() returns the right value. + { + var options = new TransactionOptions(); + options.MaxAttempts = 42; + AssertEq(options.ToString(), "TransactionOptions{MaxAttempts=42}"); + } + }); + } + // Tests the overload of RunTransactionAsync() where the update function returns a non-generic // Task object. Task TestTransactionWithNonGenericTask() { @@ -1436,6 +1475,28 @@ Task TestTransactionsInParallel() { }); } + Task TestTransactionWithExplicitMaxAttempts() { + return Async(() => { + var options = new TransactionOptions(); + options.MaxAttempts = 3; + int numAttempts = 0; + DocumentReference doc = TestDocument(); + + Task txnTask = db.RunTransactionAsync(options, transaction => { + numAttempts++; + return transaction.GetSnapshotAsync(doc).ContinueWith(snapshot => { + // Queue a write via the transaction. + transaction.Set(doc, TestData(0)); + // But also write the document (out-of-band) so the transaction is retried. + return doc.SetAsync(TestData(numAttempts)); + }).Unwrap(); + }); + + AssertTaskFaults(txnTask, FirestoreError.FailedPrecondition); + AssertEq(numAttempts, 3); + }); + } + Task TestTransactionMaxRetryFailure() { return Async(() => { int retries = 0; @@ -1708,9 +1769,9 @@ Task TestSnapshotMetadataEqualsAndGetHashCode() { /* Anonymous authentication must be enabled for TestAuthIntegration to pass. - + Also, the following security rules are required for TestAuthIntegrationt to pass: - + rules_version='2' service cloud.firestore { match /databases/{database}/documents {