Repository navigation
fix(cloud_firestore): deliver query snapshots before snapshotsInSync #18921
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
375e907
053a807
8828bcf
f4a81f3
4338fd2
bb3ae6c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -62,6 +62,109 @@ void runInstanceTests() { | |
| await snapshots.cancel(); | ||
| }, skip: kIsWeb); | ||
|
|
||
| test( | ||
| 'snapshotsInSync() follows a remote query snapshot', | ||
| () async { | ||
| final writerApp = await Firebase.initializeApp( | ||
| name: 'insync-writer-${DateTime.now().microsecondsSinceEpoch}', | ||
| options: Firebase.app().options, | ||
| ); | ||
| final writer = FirebaseFirestore.instanceFor(app: writerApp); | ||
| writer.settings = const Settings(persistenceEnabled: false); | ||
| writer.useFirestoreEmulator('localhost', 8080); | ||
|
|
||
| final collection = firestore | ||
| .collection('flutter-tests') | ||
| .doc('insync-${DateTime.now().microsecondsSinceEpoch}') | ||
| .collection('items'); | ||
| const docId = 'shared'; | ||
| final events = <String>[]; | ||
|
|
||
| final querySub = collection.snapshots().listen((snapshot) { | ||
| String? value; | ||
| for (final document in snapshot.docs) { | ||
| if (document.id == docId) { | ||
| value = '${document.data()['n']}'; | ||
| } | ||
| } | ||
| events.add('query:$value:${snapshot.metadata.hasPendingWrites}'); | ||
| }); | ||
| final syncSub = firestore.snapshotsInSync().listen((_) { | ||
| events.add('sync'); | ||
| }); | ||
|
Comment on lines
+83
to
+94
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Similarly, use final querySub = collection.snapshots().listen((snapshot) {
String? value;
for (final document in snapshot.docs) {
if (document.id == docId) {
value = '${document.data()["n"]}';
}
}
events.add('query:$value:${snapshot.metadata.hasPendingWrites}');
});
addTearDown(querySub.cancel);
final syncSub = firestore.snapshotsInSync().listen((_) {
events.add('sync');
});
addTearDown(syncSub.cancel); |
||
|
|
||
| Future<void> waitUntil(bool Function() ready) async { | ||
| final deadline = DateTime.now().add(const Duration(seconds: 5)); | ||
| while (!ready()) { | ||
| expect( | ||
| DateTime.now().isBefore(deadline), | ||
| isTrue, | ||
| reason: '$events', | ||
| ); | ||
| await Future<void>.delayed(const Duration(milliseconds: 20)); | ||
| } | ||
| } | ||
|
|
||
| Future<void> settle() async { | ||
| final deadline = DateTime.now().add(const Duration(seconds: 2)); | ||
| var count = events.length; | ||
| while (DateTime.now().isBefore(deadline)) { | ||
| await Future<void>.delayed(const Duration(milliseconds: 80)); | ||
| if (events.length == count) { | ||
| return; | ||
| } | ||
| count = events.length; | ||
| } | ||
| } | ||
|
|
||
| await waitUntil( | ||
| () => | ||
| events.contains('sync') && | ||
| events.any((event) => event.startsWith('query:')), | ||
| ); | ||
| await settle(); | ||
| events.clear(); | ||
|
|
||
| for (var i = 1; i <= 24; i++) { | ||
| final marker = events.length; | ||
| await writer.collection(collection.path).doc(docId).set({'n': i}); | ||
| await waitUntil( | ||
| () => events.sublist(marker).contains('query:$i:false'), | ||
| ); | ||
| await waitUntil(() => events.sublist(marker).contains('sync')); | ||
| final slice = events.sublist(marker); | ||
| final queryAt = slice.indexOf('query:$i:false'); | ||
| final syncAt = slice.indexOf('sync'); | ||
| expect(queryAt, lessThan(syncAt), reason: 'write $i: $slice'); | ||
| await settle(); | ||
| } | ||
|
|
||
| await querySub.cancel(); | ||
| await syncSub.cancel(); | ||
| await writerApp.delete(); | ||
|
Comment on lines
+142
to
+144
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| // This database is not cached, so the lookup has to walk every | ||
| // entry, including the deleted writer's. | ||
| final uncached = FirebaseFirestore.instanceFor( | ||
| app: Firebase.app(), | ||
| databaseId: 'insync-after-delete', | ||
| ); | ||
| uncached.settings = const Settings(persistenceEnabled: false); | ||
| uncached.useFirestoreEmulator('localhost', 8080); | ||
| await uncached.doc('flutter-tests/insync-after-delete').set({ | ||
| 'ok': true, | ||
| }); | ||
| await firestore.doc('flutter-tests/insync-after-delete').set({ | ||
| 'ok': true, | ||
| }); | ||
| }, | ||
| timeout: const Timeout(Duration(minutes: 2)), | ||
| skip: | ||
| defaultTargetPlatform != TargetPlatform.iOS && | ||
| defaultTargetPlatform != TargetPlatform.macOS && | ||
| defaultTargetPlatform != TargetPlatform.android, | ||
| ); | ||
|
|
||
| test('enableNetwork()', () async { | ||
| // Write some data while online | ||
| await firestore.enableNetwork(); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -35,6 +35,8 @@ public class FLTFirebaseFirestorePlugin: NSObject, FlutterPlugin, FLTFirebasePlu | |
| private var transactionHandlers: [String: TransactionStreamHandler] = [:] | ||
| private let transactionLock = NSLock() | ||
| private let listenersLock = NSLock() | ||
| private var snapshotDeliveryQueues: [String: DispatchQueue] = [:] | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Consider removing the queue from |
||
| private let snapshotDeliveryQueuesLock = NSLock() | ||
|
|
||
| static let serverTimestampMap = NSCache<NSNumber, NSString>() | ||
|
|
||
|
|
@@ -153,6 +155,20 @@ public class FLTFirebaseFirestorePlugin: NSObject, FlutterPlugin, FLTFirebasePlu | |
| return identifier | ||
| } | ||
|
|
||
| private func snapshotDeliveryQueue(appName: String, databaseURL: String) -> DispatchQueue { | ||
| let key = "\(appName)|\(databaseURL)" | ||
| snapshotDeliveryQueuesLock.lock() | ||
| defer { snapshotDeliveryQueuesLock.unlock() } | ||
| if let queue = snapshotDeliveryQueues[key] { | ||
| return queue | ||
| } | ||
| let queue = DispatchQueue( | ||
| label: "io.flutter.plugins.firebase.firestore.snapshot_delivery.\(key)" | ||
| ) | ||
| snapshotDeliveryQueues[key] = queue | ||
| return queue | ||
| } | ||
|
|
||
| private func firestore(from pigeonApp: FirestorePigeonFirebaseApp) -> Firestore { | ||
| objc_sync_enter(self) | ||
| defer { objc_sync_exit(self) } | ||
|
|
@@ -353,7 +369,10 @@ public class FLTFirebaseFirestorePlugin: NSObject, FlutterPlugin, FLTFirebasePlu | |
| let firestore = firestore(from: app) | ||
| let identifier = registerEventChannel( | ||
| prefix: kFLTFirebaseFirestoreSnapshotsInSyncEventChannelName, | ||
| streamHandler: SnapshotsInSyncStreamHandler(firestore: firestore) | ||
| streamHandler: SnapshotsInSyncStreamHandler( | ||
| firestore: firestore, | ||
| snapshotQueue: snapshotDeliveryQueue(appName: app.appName, databaseURL: app.databaseURL) | ||
| ) | ||
| ) | ||
| completion(.success(identifier)) | ||
| } | ||
|
|
@@ -732,7 +751,8 @@ public class FLTFirebaseFirestorePlugin: NSObject, FlutterPlugin, FLTFirebasePlu | |
| serverTimestampBehavior: PigeonParser.parseServerTimestampBehavior( | ||
| options.serverTimestampBehavior | ||
| ), | ||
| source: PigeonParser.parseListenSource(source) | ||
| source: PigeonParser.parseListenSource(source), | ||
| snapshotQueue: snapshotDeliveryQueue(appName: app.appName, databaseURL: app.databaseURL) | ||
| ) | ||
| ) | ||
| completion(.success(identifier)) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To prevent resource leaks if the test fails before reaching the manual cleanup at the end of the test, use
addTearDownto guarantee that the temporary Firebase app is deleted.