Skip to content

Commit 9da7cc3

Browse files
Lyokonelesnitsky
andauthored
feat(firestore): add max attempts for Firestore transactions (#9163)
* feat(firestore): add max attempts for Firestore transactions * feat(firestore, ios): add max attempts for Firestore transactions * feat(firestore, web): add max attempts for Firestore transactions * feat(firestore): add e2e tests * feat(firestore): fix memory allocation iOS * drop fake_cloud_firestore * feat(firestore): fix memory allocation iOS Co-authored-by: Andrei Lesnitsky <[email protected]>
1 parent 5a03a85 commit 9da7cc3

File tree

13 files changed

+294
-49
lines changed

13 files changed

+294
-49
lines changed

packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/streamhandler/TransactionStreamHandler.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import com.google.firebase.firestore.FirebaseFirestoreException.Code;
1111
import com.google.firebase.firestore.SetOptions;
1212
import com.google.firebase.firestore.Transaction;
13+
import com.google.firebase.firestore.TransactionOptions;
1314
import io.flutter.plugin.common.EventChannel.EventSink;
1415
import io.flutter.plugin.common.EventChannel.StreamHandler;
1516
import io.flutter.plugins.firebase.firestore.FlutterFirebaseFirestoreTransactionResult;
@@ -57,8 +58,12 @@ public void onListen(Object arguments, EventSink events) {
5758
timeout = 5000L;
5859
}
5960

61+
// Always sent by the PlatformChannel
62+
int maxAttempts = (int) argumentsMap.get("maxAttempts");
63+
6064
firestore
6165
.runTransaction(
66+
new TransactionOptions.Builder().setMaxAttempts(maxAttempts).build(),
6267
transaction -> {
6368
onTransactionStartedListener.onStarted(transaction);
6469

packages/cloud_firestore/cloud_firestore/example/ios/Runner/Info.plist

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,7 @@
4343
</array>
4444
<key>UIViewControllerBasedStatusBarAppearance</key>
4545
<false/>
46+
<key>CADisableMinimumFrameDurationOnPhone</key>
47+
<true/>
4648
</dict>
4749
</plist>

packages/cloud_firestore/cloud_firestore/example/test_driver/transaction_e2e.dart

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44

55
import 'dart:math';
66

7+
import 'package:cloud_firestore/cloud_firestore.dart';
78
import 'package:flutter/foundation.dart';
89
import 'package:flutter_test/flutter_test.dart';
9-
import 'package:cloud_firestore/cloud_firestore.dart';
1010

1111
void runTransactionTests() {
1212
group(
@@ -91,6 +91,71 @@ void runTransactionTests() {
9191
}
9292
});
9393

94+
test('should not collide if number of maxAttempts is enough', () async {
95+
DocumentReference<Map<String, dynamic>> doc1 =
96+
await initializeTest('transaction-maxAttempts-1');
97+
98+
await doc1.set({'test': 0});
99+
100+
await Future.wait([
101+
firestore.runTransaction(
102+
(Transaction transaction) async {
103+
final value = await transaction.get(doc1);
104+
transaction.set(doc1, {
105+
'test': value['test'] + 1,
106+
});
107+
},
108+
maxAttempts: 2,
109+
),
110+
firestore.runTransaction(
111+
(Transaction transaction) async {
112+
final value = await transaction.get(doc1);
113+
transaction.set(doc1, {
114+
'test': value['test'] + 1,
115+
});
116+
},
117+
maxAttempts: 2,
118+
),
119+
]);
120+
121+
DocumentSnapshot<Map<String, dynamic>> snapshot1 = await doc1.get();
122+
expect(snapshot1.data()!['test'], equals(2));
123+
});
124+
125+
test('should collide if number of maxAttempts is too low', () async {
126+
DocumentReference<Map<String, dynamic>> doc1 =
127+
await initializeTest('transaction-maxAttempts-2');
128+
129+
await doc1.set({'test': 0});
130+
131+
await expectLater(
132+
Future.wait([
133+
firestore.runTransaction(
134+
(Transaction transaction) async {
135+
final value = await transaction.get(doc1);
136+
transaction.set(doc1, {
137+
'test': value['test'] + 1,
138+
});
139+
},
140+
maxAttempts: 1,
141+
),
142+
firestore.runTransaction(
143+
(Transaction transaction) async {
144+
final value = await transaction.get(doc1);
145+
transaction.set(doc1, {
146+
'test': value['test'] + 1,
147+
});
148+
},
149+
maxAttempts: 1,
150+
),
151+
]),
152+
throwsA(
153+
isA<FirebaseException>()
154+
.having((e) => e.code, 'code', 'failed-precondition'),
155+
),
156+
);
157+
});
158+
94159
test('runs multiple transactions in parallel', () async {
95160
DocumentReference<Map<String, dynamic>> doc1 =
96161
await initializeTest('transaction-multi-1');

packages/cloud_firestore/cloud_firestore/ios/Classes/FLTTransactionStreamHandler.m

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ - (FlutterError *_Nullable)onListenWithArguments:(id _Nullable)arguments
3737
eventSink:(nonnull FlutterEventSink)events {
3838
FIRFirestore *firestore = arguments[@"firestore"];
3939
NSNumber *transactionTimeout = arguments[@"timeout"];
40+
NSNumber *maxAttempts = arguments[@"maxAttempts"];
41+
4042
__weak FLTTransactionStreamHandler *weakSelf = self;
4143

4244
id transactionRunBlock = ^id(FIRTransaction *transaction, NSError **pError) {
@@ -133,8 +135,12 @@ - (FlutterError *_Nullable)onListenWithArguments:(id _Nullable)arguments
133135

134136
strongSelf.ended();
135137
};
138+
FIRTransactionOptions *options = [[FIRTransactionOptions alloc] init];
139+
options.maxAttempts = maxAttempts.integerValue;
136140

137-
[firestore runTransactionWithBlock:transactionRunBlock completion:transactionCompleteBlock];
141+
[firestore runTransactionWithOptions:options
142+
block:transactionRunBlock
143+
completion:transactionCompleteBlock];
138144

139145
return nil;
140146
}

packages/cloud_firestore/cloud_firestore/lib/src/firestore.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,16 +213,21 @@ class FirebaseFirestore extends FirebasePluginPlatform {
213213
///
214214
/// By default transactions are limited to 30 seconds of execution time. This
215215
/// timeout can be adjusted by setting the timeout parameter.
216+
///
217+
/// By default transactions will retry 5 times. You can change the number of attemps
218+
/// with [maxAttempts]. Attempts should be at least 1.
216219
Future<T> runTransaction<T>(
217220
TransactionHandler<T> transactionHandler, {
218221
Duration timeout = const Duration(seconds: 30),
222+
int maxAttempts = 5,
219223
}) async {
220224
late T output;
221225
await _delegate.runTransaction(
222226
(transaction) async {
223227
output = await transactionHandler(Transaction._(this, transaction));
224228
},
225229
timeout: timeout,
230+
maxAttempts: maxAttempts,
226231
);
227232

228233
return output;

packages/cloud_firestore/cloud_firestore_platform_interface/lib/src/method_channel/method_channel_firestore.dart

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ import 'method_channel_document_reference.dart';
1818
import 'method_channel_query.dart';
1919
import 'method_channel_transaction.dart';
2020
import 'method_channel_write_batch.dart';
21-
import 'utils/firestore_message_codec.dart';
2221
import 'utils/exception.dart';
22+
import 'utils/firestore_message_codec.dart';
2323

2424
/// The entry point for accessing a Firestore.
2525
///
@@ -221,6 +221,7 @@ class MethodChannelFirebaseFirestore extends FirebaseFirestorePlatform {
221221
Future<T> runTransaction<T>(
222222
TransactionHandler<T> transactionHandler, {
223223
Duration timeout = const Duration(seconds: 30),
224+
int maxAttempts = 5,
224225
}) async {
225226
assert(timeout.inMilliseconds > 0,
226227
'Transaction timeout must be more than 0 milliseconds');
@@ -243,7 +244,11 @@ class MethodChannelFirebaseFirestore extends FirebaseFirestorePlatform {
243244
);
244245

245246
snapshotStream = eventChannel.receiveBroadcastStream(
246-
<String, dynamic>{'firestore': this, 'timeout': timeout.inMilliseconds},
247+
<String, dynamic>{
248+
'firestore': this,
249+
'timeout': timeout.inMilliseconds,
250+
'maxAttempts': maxAttempts,
251+
},
247252
).listen(
248253
(event) async {
249254
if (event['error'] != null) {

packages/cloud_firestore/cloud_firestore_platform_interface/lib/src/platform_interface/platform_interface_firestore.dart

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import 'package:flutter/foundation.dart';
1111
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
1212

1313
import '../get_options.dart';
14-
import '../persistence_settings.dart';
1514
import '../method_channel/method_channel_firestore.dart';
15+
import '../persistence_settings.dart';
1616
import '../settings.dart';
1717
import 'platform_interface_collection_reference.dart';
1818
import 'platform_interface_document_reference.dart';
@@ -162,8 +162,11 @@ abstract class FirebaseFirestorePlatform extends PlatformInterface {
162162
///
163163
/// By default transactions are limited to 5 seconds of execution time. This
164164
/// timeout can be adjusted by setting the [timeout] parameter.
165+
///
166+
/// By default transactions will retry 5 times. You can change the number of attemps
167+
/// with [maxAttempts]. Attempts should be at least 1.
165168
Future<T?> runTransaction<T>(TransactionHandler<T> transactionHandler,
166-
{Duration timeout = const Duration(seconds: 30)}) {
169+
{Duration timeout = const Duration(seconds: 30), int maxAttempts = 5}) {
167170
throw UnimplementedError('runTransaction() is not implemented');
168171
}
169172

packages/cloud_firestore/cloud_firestore_web/lib/cloud_firestore_web.dart

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,12 @@ import 'package:firebase_core_web/firebase_core_web_interop.dart'
1616
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
1717

1818
import 'src/collection_reference_web.dart';
19-
import 'src/field_value_factory_web.dart';
2019
import 'src/document_reference_web.dart';
20+
import 'src/field_value_factory_web.dart';
21+
import 'src/interop/firestore.dart' as firestore_interop;
2122
import 'src/query_web.dart';
2223
import 'src/transaction_web.dart';
2324
import 'src/write_batch_web.dart';
24-
import 'src/interop/firestore.dart' as firestore_interop;
2525

2626
/// Web implementation for [FirebaseFirestorePlatform]
2727
/// delegates calls to firestore web plugin
@@ -100,13 +100,20 @@ class FirebaseFirestoreWeb extends FirebaseFirestorePlatform {
100100
}
101101

102102
@override
103-
Future<T?> runTransaction<T>(TransactionHandler<T> transactionHandler,
104-
{Duration timeout = const Duration(seconds: 30)}) async {
103+
Future<T?> runTransaction<T>(
104+
TransactionHandler<T> transactionHandler, {
105+
Duration timeout = const Duration(seconds: 30),
106+
int maxAttempts = 5,
107+
}) async {
105108
await convertWebExceptions(() {
106-
return _delegate.runTransaction((transaction) async {
107-
return transactionHandler(
108-
TransactionWeb(this, _delegate, transaction!));
109-
}).timeout(timeout);
109+
return _delegate
110+
.runTransaction(
111+
(transaction) async => transactionHandler(
112+
TransactionWeb(this, _delegate, transaction!),
113+
),
114+
maxAttempts,
115+
)
116+
.timeout(timeout);
110117
});
111118
// Workaround for 'Runtime type information not available for type_variable_local'
112119
// See: https://github.com/dart-lang/sdk/issues/29722

packages/cloud_firestore/cloud_firestore_web/lib/src/interop/firestore.dart

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,13 +95,16 @@ class Firestore extends JsObjectWrapper<firestore_interop.FirestoreJsImpl> {
9595
Future<void> clearPersistence() =>
9696
handleThenable(firestore_interop.clearIndexedDbPersistence(jsObject));
9797

98-
Future runTransaction(Function(Transaction?) updateFunction) {
98+
Future runTransaction(
99+
Function(Transaction?) updateFunction, int maxAttempts) {
99100
var updateFunctionWrap = allowInterop((transaction) =>
100101
handleFutureWithMapper(
101102
updateFunction(Transaction.getInstance(transaction)), jsify));
102103

103-
return handleThenable(
104-
firestore_interop.runTransaction(jsObject, updateFunctionWrap))
104+
return handleThenable(firestore_interop.runTransaction(
105+
jsObject,
106+
updateFunctionWrap,
107+
firestore_interop.TransactionOptionsJsImpl(maxAttempts)))
105108
.then((value) => dartify(null));
106109
}
107110

packages/cloud_firestore/cloud_firestore_web/lib/src/interop/firestore_interop.dart

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@
88
@JS('firebase_firestore')
99
library firebase_interop.firestore;
1010

11-
import './firestore.dart';
12-
import 'package:firebase_core_web/firebase_core_web_interop.dart';
1311
import 'dart:typed_data' show Uint8List;
1412

13+
import 'package:firebase_core_web/firebase_core_web_interop.dart';
1514
import 'package:js/js.dart';
1615

16+
import './firestore.dart';
17+
1718
@JS()
1819
external FirestoreJsImpl getFirestore([AppJsImpl? app]);
1920

@@ -187,8 +188,17 @@ external bool refEqual(
187188
@JS()
188189
external PromiseJsImpl<void> runTransaction(
189190
FirestoreJsImpl firestore,
190-
Func1<TransactionJsImpl, PromiseJsImpl> updateFunction,
191-
);
191+
Func1<TransactionJsImpl, PromiseJsImpl> updateFunction, [
192+
TransactionOptionsJsImpl? options,
193+
]);
194+
195+
@JS('TransactionOptions')
196+
class TransactionOptionsJsImpl {
197+
external factory TransactionOptionsJsImpl(num maxAttempts);
198+
199+
/// Maximum number of attempts to commit, after which transaction fails. Default is 5.
200+
external num get maxAttempts;
201+
}
192202

193203
@JS()
194204
external FieldValue serverTimestamp();

0 commit comments

Comments
 (0)