From afea8d5aacf474b57b4364feda08be9ca195594b Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Tue, 12 Jun 2018 10:58:35 -0700 Subject: Refactor Remote Event (#1396) --- .../Example/Tests/Core/FSTQueryListenerTests.mm | 14 +- Firestore/Example/Tests/Core/FSTViewTests.mm | 46 +- .../Example/Tests/Integration/FSTDatastoreTests.mm | 5 + .../Example/Tests/Local/FSTLocalStoreTests.mm | 32 +- .../Example/Tests/Remote/FSTRemoteEventTests.mm | 906 ++++++++++------- Firestore/Example/Tests/SpecTests/FSTSpecTests.mm | 13 +- .../SpecTests/json/existence_filter_spec_test.json | 940 +++++++++++++++-- .../Tests/SpecTests/json/limbo_spec_test.json | 411 ++++++++ .../Tests/SpecTests/json/limit_spec_test.json | 20 +- .../Tests/SpecTests/json/listen_spec_test.json | 1075 ++++++++++++++++++++ Firestore/Example/Tests/Util/FSTHelpers.h | 54 +- Firestore/Example/Tests/Util/FSTHelpers.mm | 135 ++- Firestore/Source/Core/FSTSyncEngine.mm | 45 +- Firestore/Source/Core/FSTView.mm | 30 +- Firestore/Source/Local/FSTLocalStore.mm | 38 +- Firestore/Source/Remote/FSTRemoteEvent.h | 199 ++-- Firestore/Source/Remote/FSTRemoteEvent.mm | 853 ++++++++-------- Firestore/Source/Remote/FSTRemoteStore.h | 10 +- Firestore/Source/Remote/FSTRemoteStore.mm | 204 ++-- Firestore/Source/Remote/FSTWatchChange.mm | 2 +- 20 files changed, 3753 insertions(+), 1279 deletions(-) (limited to 'Firestore') diff --git a/Firestore/Example/Tests/Core/FSTQueryListenerTests.mm b/Firestore/Example/Tests/Core/FSTQueryListenerTests.mm index 5629075..ddd831a 100644 --- a/Firestore/Example/Tests/Core/FSTQueryListenerTests.mm +++ b/Firestore/Example/Tests/Core/FSTQueryListenerTests.mm @@ -118,11 +118,7 @@ NS_ASSUME_NONNULL_BEGIN FSTView *view = [[FSTView alloc] initWithQuery:query remoteDocuments:DocumentKeySet{}]; FSTViewSnapshot *snap1 = FSTTestApplyChanges(view, @[], nil); - - FSTTargetChange *ackTarget = - [FSTTargetChange changeWithDocuments:@[] - currentStatusUpdate:FSTCurrentStatusUpdateMarkCurrent]; - FSTViewSnapshot *snap2 = FSTTestApplyChanges(view, @[], ackTarget); + FSTViewSnapshot *snap2 = FSTTestApplyChanges(view, @[], FSTTestTargetChangeMarkCurrent()); [listener queryDidChangeViewSnapshot:snap1]; XCTAssertEqualObjects(accum, @[]); @@ -188,9 +184,7 @@ NS_ASSUME_NONNULL_BEGIN FSTView *view = [[FSTView alloc] initWithQuery:query remoteDocuments:DocumentKeySet{}]; FSTViewSnapshot *snap1 = FSTTestApplyChanges(view, @[ doc1 ], nil); - FSTTargetChange *ackTarget = - [FSTTargetChange changeWithDocuments:@[ doc1 ] - currentStatusUpdate:FSTCurrentStatusUpdateMarkCurrent]; + FSTTargetChange *ackTarget = FSTTestTargetChangeAckDocuments({doc1.key}); FSTViewSnapshot *snap2 = FSTTestApplyChanges(view, @[], ackTarget); FSTViewSnapshot *snap3 = FSTTestApplyChanges(view, @[ doc2 ], nil); @@ -342,9 +336,7 @@ NS_ASSUME_NONNULL_BEGIN FSTViewSnapshot *snap1 = FSTTestApplyChanges(view, @[ doc1 ], nil); FSTViewSnapshot *snap2 = FSTTestApplyChanges(view, @[ doc2 ], nil); FSTViewSnapshot *snap3 = - FSTTestApplyChanges(view, @[], - [FSTTargetChange changeWithDocuments:@[ doc1, doc2 ] - currentStatusUpdate:FSTCurrentStatusUpdateMarkCurrent]); + FSTTestApplyChanges(view, @[], FSTTestTargetChangeAckDocuments({doc1.key, doc2.key})); [listener applyChangedOnlineState:FSTOnlineStateOnline]; // no event [listener queryDidChangeViewSnapshot:snap1]; diff --git a/Firestore/Example/Tests/Core/FSTViewTests.mm b/Firestore/Example/Tests/Core/FSTViewTests.mm index ec62d82..8c8d8a0 100644 --- a/Firestore/Example/Tests/Core/FSTViewTests.mm +++ b/Firestore/Example/Tests/Core/FSTViewTests.mm @@ -56,10 +56,8 @@ NS_ASSUME_NONNULL_BEGIN FSTDocument *doc2 = FSTTestDoc("rooms/eros/messages/2", 0, @{@"text" : @"msg2"}, NO); FSTDocument *doc3 = FSTTestDoc("rooms/other/messages/1", 0, @{@"text" : @"msg3"}, NO); - FSTViewSnapshot *_Nullable snapshot = - FSTTestApplyChanges(view, @[ doc1, doc2, doc3 ], - [FSTTargetChange changeWithDocuments:@[ doc1, doc2, doc3 ] - currentStatusUpdate:FSTCurrentStatusUpdateMarkCurrent]); + FSTViewSnapshot *_Nullable snapshot = FSTTestApplyChanges( + view, @[ doc1, doc2, doc3 ], FSTTestTargetChangeAckDocuments({doc1.key, doc2.key, doc3.key})); XCTAssertEqual(snapshot.query, query); @@ -90,8 +88,7 @@ NS_ASSUME_NONNULL_BEGIN // delete doc2, add doc3 FSTViewSnapshot *snapshot = FSTTestApplyChanges(view, @[ FSTTestDeletedDoc("rooms/eros/messages/2", 0), doc3 ], - [FSTTargetChange changeWithDocuments:@[ doc1, doc3 ] - currentStatusUpdate:FSTCurrentStatusUpdateMarkCurrent]); + FSTTestTargetChangeAckDocuments({doc1.key, doc3.key})); XCTAssertEqual(snapshot.query, query); @@ -216,10 +213,8 @@ NS_ASSUME_NONNULL_BEGIN FSTTestApplyChanges(view, @[ doc1, doc3 ], nil); // add doc2, which should push out doc3 - FSTViewSnapshot *snapshot = - FSTTestApplyChanges(view, @[ doc2 ], - [FSTTargetChange changeWithDocuments:@[ doc1, doc2, doc3 ] - currentStatusUpdate:FSTCurrentStatusUpdateMarkCurrent]); + FSTViewSnapshot *snapshot = FSTTestApplyChanges( + view, @[ doc2 ], FSTTestTargetChangeAckDocuments({doc1.key, doc2.key, doc3.key})); XCTAssertEqual(snapshot.query, query); @@ -263,9 +258,8 @@ NS_ASSUME_NONNULL_BEGIN previousChanges:viewDocChanges]; FSTViewSnapshot *snapshot = [view applyChangesToDocuments:viewDocChanges - targetChange:[FSTTargetChange - changeWithDocuments:@[ doc1, doc2, doc3, doc4 ] - currentStatusUpdate:FSTCurrentStatusUpdateMarkCurrent]] + targetChange:FSTTestTargetChangeAckDocuments( + {doc1.key, doc2.key, doc3.key, doc4.key})] .snapshot; XCTAssertEqual(snapshot.query, query); @@ -294,27 +288,21 @@ NS_ASSUME_NONNULL_BEGIN applyChangesToDocuments:[view computeChangesWithDocuments:FSTTestDocUpdates(@[ doc1 ])]]; XCTAssertEqualObjects(change.limboChanges, @[]); - change = - [view applyChangesToDocuments:[view computeChangesWithDocuments:FSTTestDocUpdates(@[])] - targetChange:[FSTTargetChange - changeWithDocuments:@[] - currentStatusUpdate:FSTCurrentStatusUpdateMarkCurrent]]; + change = [view applyChangesToDocuments:[view computeChangesWithDocuments:FSTTestDocUpdates(@[])] + targetChange:FSTTestTargetChangeMarkCurrent()]; XCTAssertEqualObjects( change.limboChanges, @[ [FSTLimboDocumentChange changeWithType:FSTLimboDocumentChangeTypeAdded key:doc1.key] ]); - change = [view - applyChangesToDocuments:[view computeChangesWithDocuments:FSTTestDocUpdates(@[])] - targetChange:[FSTTargetChange changeWithDocuments:@[ doc1 ] - currentStatusUpdate:FSTCurrentStatusUpdateNone]]; + change = [view applyChangesToDocuments:[view computeChangesWithDocuments:FSTTestDocUpdates(@[])] + targetChange:FSTTestTargetChangeAckDocuments({doc1.key})]; XCTAssertEqualObjects( change.limboChanges, @[ [FSTLimboDocumentChange changeWithType:FSTLimboDocumentChangeTypeRemoved key:doc1.key] ]); - change = [view - applyChangesToDocuments:[view computeChangesWithDocuments:FSTTestDocUpdates(@[ doc2 ])] - targetChange:[FSTTargetChange changeWithDocuments:@[ doc2 ] - currentStatusUpdate:FSTCurrentStatusUpdateNone]]; + change = + [view applyChangesToDocuments:[view computeChangesWithDocuments:FSTTestDocUpdates(@[ doc2 ])] + targetChange:FSTTestTargetChangeAckDocuments({doc2.key})]; XCTAssertEqualObjects(change.limboChanges, @[]); change = [view @@ -343,11 +331,9 @@ NS_ASSUME_NONNULL_BEGIN FSTView *view = [[FSTView alloc] initWithQuery:query remoteDocuments:DocumentKeySet{doc1.key, doc2.key}]; - FSTTargetChange *markCurrent = - [FSTTargetChange changeWithDocuments:@[] - currentStatusUpdate:FSTCurrentStatusUpdateMarkCurrent]; FSTViewDocumentChanges *changes = [view computeChangesWithDocuments:FSTTestDocUpdates(@[])]; - FSTViewChange *change = [view applyChangesToDocuments:changes targetChange:markCurrent]; + FSTViewChange *change = + [view applyChangesToDocuments:changes targetChange:FSTTestTargetChangeMarkCurrent()]; XCTAssertEqualObjects(change.limboChanges, @[]); } diff --git a/Firestore/Example/Tests/Integration/FSTDatastoreTests.mm b/Firestore/Example/Tests/Integration/FSTDatastoreTests.mm index e6e1a19..cba9017 100644 --- a/Firestore/Example/Tests/Integration/FSTDatastoreTests.mm +++ b/Firestore/Example/Tests/Integration/FSTDatastoreTests.mm @@ -48,6 +48,7 @@ namespace util = firebase::firestore::util; using firebase::firestore::auth::EmptyCredentialsProvider; using firebase::firestore::core::DatabaseInfo; using firebase::firestore::model::DatabaseId; +using firebase::firestore::model::DocumentKeySet; using firebase::firestore::model::Precondition; using firebase::firestore::model::TargetId; @@ -121,6 +122,10 @@ NS_ASSUME_NONNULL_BEGIN HARD_FAIL("Not implemented"); } +- (DocumentKeySet)remoteKeysForTarget:(FSTBoxedTargetID *)targetId { + return DocumentKeySet{}; +} + - (void)applyRemoteEvent:(FSTRemoteEvent *)remoteEvent { [self.listenEvents addObject:remoteEvent]; XCTestExpectation *expectation = [self.listenEventExpectations objectAtIndex:0]; diff --git a/Firestore/Example/Tests/Local/FSTLocalStoreTests.mm b/Firestore/Example/Tests/Local/FSTLocalStoreTests.mm index e10fb12..8760571 100644 --- a/Firestore/Example/Tests/Local/FSTLocalStoreTests.mm +++ b/Firestore/Example/Tests/Local/FSTLocalStoreTests.mm @@ -609,8 +609,8 @@ NS_ASSUME_NONNULL_BEGIN FSTQuery *query = FSTTestQuery("foo"); FSTTargetID targetID = [self allocateQuery:query]; - [self applyRemoteEvent:FSTTestUpdateRemoteEvent(FSTTestDoc("foo/bar", 2, @{@"foo" : @"bar"}, NO), - @[ @(targetID) ], @[])]; + [self applyRemoteEvent:FSTTestAddedRemoteEvent(FSTTestDoc("foo/bar", 2, @{@"foo" : @"bar"}, NO), + @[ @(targetID) ])]; [self collectGarbage]; FSTAssertContains(FSTTestDoc("foo/bar", 2, @{@"foo" : @"bar"}, NO)); @@ -703,8 +703,8 @@ NS_ASSUME_NONNULL_BEGIN FSTQuery *query = FSTTestQuery("foo"); FSTTargetID targetID = [self allocateQuery:query]; - [self applyRemoteEvent:FSTTestUpdateRemoteEvent(FSTTestDoc("foo/bar", 1, @{@"foo" : @"bar"}, NO), - @[ @(targetID) ], @[])]; + [self applyRemoteEvent:FSTTestAddedRemoteEvent(FSTTestDoc("foo/bar", 1, @{@"foo" : @"bar"}, NO), + @[ @(targetID) ])]; [self writeMutation:FSTTestSetMutation(@"foo/baz", @{@"foo" : @"baz"})]; [self collectGarbage]; FSTAssertContains(FSTTestDoc("foo/bar", 1, @{@"foo" : @"bar"}, NO)); @@ -805,21 +805,19 @@ NS_ASSUME_NONNULL_BEGIN FSTBoxedTargetID *targetID = @(queryData.targetID); NSData *resumeToken = FSTTestResumeTokenFromSnapshotVersion(1000); - FSTWatchChange *watchChange = + FSTWatchTargetChange *watchChange = [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateCurrent targetIDs:@[ targetID ] resumeToken:resumeToken]; NSMutableDictionary *listens = [NSMutableDictionary dictionary]; listens[targetID] = queryData; - NSMutableDictionary *pendingResponses = - [NSMutableDictionary dictionary]; - FSTWatchChangeAggregator *aggregator = - [[FSTWatchChangeAggregator alloc] initWithSnapshotVersion:testutil::Version(1000) - listenTargets:listens - pendingTargetResponses:pendingResponses]; - [aggregator addWatchChanges:@[ watchChange ]]; - FSTRemoteEvent *remoteEvent = [aggregator remoteEvent]; + FSTWatchChangeAggregator *aggregator = [[FSTWatchChangeAggregator alloc] + initWithTargetMetadataProvider:[FSTTestTargetMetadataProvider + providerWithSingleResultForKey:testutil::Key("foo/bar") + targets:@[ targetID ]]]; + [aggregator handleTargetChange:watchChange]; + FSTRemoteEvent *remoteEvent = [aggregator remoteEventAtSnapshotVersion:testutil::Version(1000)]; [self applyRemoteEvent:remoteEvent]; // Stop listening so that the query should become inactive (but persistent) @@ -842,10 +840,10 @@ NS_ASSUME_NONNULL_BEGIN [self allocateQuery:query]; FSTAssertTargetID(2); - [self applyRemoteEvent:FSTTestUpdateRemoteEvent(FSTTestDoc("foo/baz", 10, @{@"a" : @"b"}, NO), - @[ @2 ], @[])]; - [self applyRemoteEvent:FSTTestUpdateRemoteEvent(FSTTestDoc("foo/bar", 20, @{@"a" : @"b"}, NO), - @[ @2 ], @[])]; + [self applyRemoteEvent:FSTTestAddedRemoteEvent(FSTTestDoc("foo/baz", 10, @{@"a" : @"b"}, NO), + @[ @2 ])]; + [self applyRemoteEvent:FSTTestAddedRemoteEvent(FSTTestDoc("foo/bar", 20, @{@"a" : @"b"}, NO), + @[ @2 ])]; [self.localStore locallyWriteMutations:@[ FSTTestSetMutation(@"foo/bonk", @{@"a" : @"b"}) ]]; diff --git a/Firestore/Example/Tests/Remote/FSTRemoteEventTests.mm b/Firestore/Example/Tests/Remote/FSTRemoteEventTests.mm index 84d0fa1..c6936f7 100644 --- a/Firestore/Example/Tests/Remote/FSTRemoteEventTests.mm +++ b/Firestore/Example/Tests/Remote/FSTRemoteEventTests.mm @@ -34,6 +34,7 @@ namespace testutil = firebase::firestore::testutil; using firebase::firestore::model::DocumentKey; using firebase::firestore::model::DocumentKeySet; +using firebase::firestore::model::SnapshotVersion; NS_ASSUME_NONNULL_BEGIN @@ -42,86 +43,204 @@ NS_ASSUME_NONNULL_BEGIN @implementation FSTRemoteEventTests { NSData *_resumeToken1; - NSMutableDictionary *_noPendingResponses; + NSMutableDictionary *_noOutstandingResponses; + FSTTestTargetMetadataProvider *_targetMetadataProvider; } - (void)setUp { _resumeToken1 = [@"resume1" dataUsingEncoding:NSUTF8StringEncoding]; - _noPendingResponses = [NSMutableDictionary dictionary]; + _noOutstandingResponses = [NSMutableDictionary dictionary]; + _targetMetadataProvider = [FSTTestTargetMetadataProvider new]; } -- (FSTWatchChangeAggregator *)aggregatorWithTargets:(NSArray *)targets - outstanding: - (NSDictionary *)outstanding - changes:(NSArray *)watchChanges { - NSMutableDictionary *listens = [NSMutableDictionary dictionary]; - FSTQueryData *dummyQueryData = [FSTQueryData alloc]; - for (NSNumber *targetID in targets) { - listens[targetID] = dummyQueryData; +/** + * Creates a map with query data for the provided target IDs. All targets are considered active + * and query a collection named "coll". + */ +- (NSDictionary *)queryDataForTargets: + (NSArray *)targetIDs { + NSMutableDictionary *targets = + [NSMutableDictionary dictionary]; + for (FSTBoxedTargetID *targetID in targetIDs) { + FSTQuery *query = FSTTestQuery("coll"); + targets[targetID] = [[FSTQueryData alloc] initWithQuery:query + targetID:targetID.intValue + listenSequenceNumber:0 + purpose:FSTQueryPurposeListen]; + } + return targets; +} + +/** + * Creates a map with query data for the provided target IDs. All targets are marked as limbo + * queries for the document at "coll/limbo". + */ +- (NSDictionary *)queryDataForLimboTargets: + (NSArray *)targetIDs { + NSMutableDictionary *targets = + [NSMutableDictionary dictionary]; + for (FSTBoxedTargetID *targetID in targetIDs) { + FSTQuery *query = FSTTestQuery("coll/limbo"); + targets[targetID] = [[FSTQueryData alloc] initWithQuery:query + targetID:targetID.intValue + listenSequenceNumber:0 + purpose:FSTQueryPurposeLimboResolution]; } + return targets; +} + +/** + * Creates an aggregator initialized with the set of provided FSTWatchChanges. Tests can add further + * changes via `handleDocumentChange`, `handleTargetChange` and `handleExistenceFilterChange`. + * + * @param snapshotVersion The version at which to create the remote event. This corresponds to the + * snapshot version provided by a NO_CHANGE event. + * @param targetMap A map of query data for all active targets. The map must include an entry for + * every target referenced by any of the watch changes. + * @param outstandingResponses The number of outstanding ACKs a target has to receive before it is + * considered active, or `_noOutstandingResponses` if all targets are already active. + * @param existingKeys The set of documents that are considered synced with the test targets as + * part of a previous listen. To modify this set during test execution, invoke + * `[_targetMetadataProvider setSyncedKeys:forQueryData:]`. + * @param watchChanges The watch changes to apply before returning the aggregator. Supported + * changes are FSTDocumentWatchChange and FSTWatchTargetChange. + */ +- (FSTWatchChangeAggregator *) +aggregatorWithTargetMap:(NSDictionary *)targetMap + outstandingResponses: + (nullable NSDictionary *)outstandingResponses + existingKeys:(DocumentKeySet)existingKeys + changes:(NSArray *)watchChanges { FSTWatchChangeAggregator *aggregator = - [[FSTWatchChangeAggregator alloc] initWithSnapshotVersion:testutil::Version(3) - listenTargets:listens - pendingTargetResponses:outstanding]; - [aggregator addWatchChanges:watchChanges]; + [[FSTWatchChangeAggregator alloc] initWithTargetMetadataProvider:_targetMetadataProvider]; + + NSMutableArray *targetIDs = [NSMutableArray array]; + [targetMap enumerateKeysAndObjectsUsingBlock:^(FSTBoxedTargetID *targetID, + FSTQueryData *queryData, BOOL *stop) { + [targetIDs addObject:targetID]; + [_targetMetadataProvider setSyncedKeys:existingKeys forQueryData:queryData]; + }]; + + [outstandingResponses + enumerateKeysAndObjectsUsingBlock:^(FSTBoxedTargetID *targetID, NSNumber *count, BOOL *stop) { + for (int i = 0; i < count.intValue; ++i) { + [aggregator recordTargetRequest:targetID]; + } + }]; + + for (FSTWatchChange *change in watchChanges) { + if ([change isKindOfClass:[FSTDocumentWatchChange class]]) { + [aggregator handleDocumentChange:(FSTDocumentWatchChange *)change]; + } else if ([change isKindOfClass:[FSTWatchTargetChange class]]) { + [aggregator handleTargetChange:(FSTWatchTargetChange *)change]; + } else { + HARD_ASSERT("Encountered unexpected type of FSTWatchChange"); + } + } + + [aggregator handleTargetChange:[[FSTWatchTargetChange alloc] + initWithState:FSTWatchTargetChangeStateNoChange + targetIDs:targetIDs + resumeToken:_resumeToken1 + cause:nil]]; + return aggregator; } +/** + * Creates a single remote event that includes target changes for all provided FSTWatchChanges. + * + * @param snapshotVersion The version at which to create the remote event. This corresponds to the + * snapshot version provided by the NO_CHANGE event. + * @param targetMap A map of query data for all active targets. The map must include an entry for + * every target referenced by any of the watch changes. + * @param outstandingResponses The number of outstanding ACKs a target has to receive before it is + * considered active, or `_noOutstandingResponses` if all targets are already active. + * @param existingKeys The set of documents that are considered synced with the test targets as + * part of a previous listen. + * @param watchChanges The watch changes to apply before creating the remote event. Supported + * changes are FSTDocumentWatchChange and FSTWatchTargetChange. + */ +- (FSTRemoteEvent *) +remoteEventAtSnapshotVersion:(FSTTestSnapshotVersion)snapshotVersion + targetMap:(NSDictionary *)targetMap + outstandingResponses: + (nullable NSDictionary *)outstandingResponses + existingKeys:(DocumentKeySet)existingKeys + changes:(NSArray *)watchChanges { + FSTWatchChangeAggregator *aggregator = [self aggregatorWithTargetMap:targetMap + outstandingResponses:outstandingResponses + existingKeys:existingKeys + changes:watchChanges]; + return [aggregator remoteEventAtSnapshotVersion:testutil::Version(snapshotVersion)]; +} + - (void)testWillAccumulateDocumentAddedAndRemovedEvents { - FSTDocument *doc1 = FSTTestDoc("docs/1", 1, @{ @"value" : @1 }, NO); - FSTDocument *doc2 = FSTTestDoc("docs/2", 2, @{ @"value" : @2 }, NO); + // The target map that contains an entry for every target in this test. If a target ID is omitted, + // the target is considered inactive and FSTTestTargetMetadataProvider will fail on access. + NSDictionary *targetMap = + [self queryDataForTargets:@[ @1, @2, @3, @4, @5, @6 ]]; + FSTDocument *existingDoc = FSTTestDoc("docs/1", 1, @{ @"value" : @1 }, NO); FSTWatchChange *change1 = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @1, @2, @3 ] removedTargetIDs:@[ @4, @5, @6 ] - documentKey:doc1.key - document:doc1]; + documentKey:existingDoc.key + document:existingDoc]; + FSTDocument *newDoc = FSTTestDoc("docs/2", 2, @{ @"value" : @2 }, NO); FSTWatchChange *change2 = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @1, @4 ] removedTargetIDs:@[ @2, @6 ] - documentKey:doc2.key - document:doc2]; - - FSTWatchChangeAggregator *aggregator = [self aggregatorWithTargets:@[ @1, @2, @3, @4, @5, @6 ] - outstanding:_noPendingResponses - changes:@[ change1, change2 ]]; - - FSTRemoteEvent *event = [aggregator remoteEvent]; + documentKey:newDoc.key + document:newDoc]; + + // Create a remote event that includes both `change1` and `change2` as well as a NO_CHANGE event + // with the default resume token (`_resumeToken1`). + // As `existingDoc` is provided as an existing key, any updates to this document will be treated + // as modifications rather than adds. + FSTRemoteEvent *event = [self remoteEventAtSnapshotVersion:3 + targetMap:targetMap + outstandingResponses:_noOutstandingResponses + existingKeys:DocumentKeySet{existingDoc.key} + changes:@[ change1, change2 ]]; XCTAssertEqual(event.snapshotVersion, testutil::Version(3)); XCTAssertEqual(event.documentUpdates.size(), 2); - XCTAssertEqualObjects(event.documentUpdates.at(doc1.key), doc1); - XCTAssertEqualObjects(event.documentUpdates.at(doc2.key), doc2); + XCTAssertEqualObjects(event.documentUpdates.at(existingDoc.key), existingDoc); + XCTAssertEqualObjects(event.documentUpdates.at(newDoc.key), newDoc); - XCTAssertEqual(event.targetChanges.count, 6); + // 'change1' and 'change2' affect six different targets + XCTAssertEqual(event.targetChanges.size(), 6); - FSTUpdateMapping *mapping1 = - [FSTUpdateMapping mappingWithAddedDocuments:@[ doc1, doc2 ] removedDocuments:@[]]; - XCTAssertEqualObjects(event.targetChanges[@1].mapping, mapping1); + FSTTargetChange *targetChange1 = + FSTTestTargetChange(DocumentKeySet{newDoc.key}, DocumentKeySet{existingDoc.key}, + DocumentKeySet{}, _resumeToken1, NO); + XCTAssertEqualObjects(event.targetChanges.at(1), targetChange1); - FSTUpdateMapping *mapping2 = - [FSTUpdateMapping mappingWithAddedDocuments:@[ doc1 ] removedDocuments:@[ doc2 ]]; - XCTAssertEqualObjects(event.targetChanges[@2].mapping, mapping2); + FSTTargetChange *targetChange2 = FSTTestTargetChange( + DocumentKeySet{}, DocumentKeySet{existingDoc.key}, DocumentKeySet{}, _resumeToken1, NO); + XCTAssertEqualObjects(event.targetChanges.at(2), targetChange2); - FSTUpdateMapping *mapping3 = - [FSTUpdateMapping mappingWithAddedDocuments:@[ doc1 ] removedDocuments:@[]]; - XCTAssertEqualObjects(event.targetChanges[@3].mapping, mapping3); + FSTTargetChange *targetChange3 = FSTTestTargetChange( + DocumentKeySet{}, DocumentKeySet{existingDoc.key}, DocumentKeySet{}, _resumeToken1, NO); + XCTAssertEqualObjects(event.targetChanges.at(3), targetChange3); - FSTUpdateMapping *mapping4 = - [FSTUpdateMapping mappingWithAddedDocuments:@[ doc2 ] removedDocuments:@[ doc1 ]]; - XCTAssertEqualObjects(event.targetChanges[@4].mapping, mapping4); + FSTTargetChange *targetChange4 = + FSTTestTargetChange(DocumentKeySet{newDoc.key}, DocumentKeySet{}, + DocumentKeySet{existingDoc.key}, _resumeToken1, NO); + XCTAssertEqualObjects(event.targetChanges.at(4), targetChange4); - FSTUpdateMapping *mapping5 = - [FSTUpdateMapping mappingWithAddedDocuments:@[] removedDocuments:@[ doc1 ]]; - XCTAssertEqualObjects(event.targetChanges[@5].mapping, mapping5); + FSTTargetChange *targetChange5 = FSTTestTargetChange( + DocumentKeySet{}, DocumentKeySet{}, DocumentKeySet{existingDoc.key}, _resumeToken1, NO); + XCTAssertEqualObjects(event.targetChanges.at(5), targetChange5); - FSTUpdateMapping *mapping6 = - [FSTUpdateMapping mappingWithAddedDocuments:@[] removedDocuments:@[ doc1, doc2 ]]; - XCTAssertEqualObjects(event.targetChanges[@6].mapping, mapping6); + FSTTargetChange *targetChange6 = FSTTestTargetChange( + DocumentKeySet{}, DocumentKeySet{}, DocumentKeySet{existingDoc.key}, _resumeToken1, NO); + XCTAssertEqualObjects(event.targetChanges.at(6), targetChange6); } - (void)testWillIgnoreEventsForPendingTargets { - FSTDocument *doc1 = FSTTestDoc("docs/1", 1, @{ @"value" : @1 }, NO); - FSTDocument *doc2 = FSTTestDoc("docs/2", 2, @{ @"value" : @2 }, NO); + NSDictionary *targetMap = [self queryDataForTargets:@[ @1 ]]; + FSTDocument *doc1 = FSTTestDoc("docs/1", 1, @{ @"value" : @1 }, NO); FSTWatchChange *change1 = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @1 ] removedTargetIDs:@[] documentKey:doc1.key @@ -135,31 +254,34 @@ NS_ASSUME_NONNULL_BEGIN targetIDs:@[ @1 ] cause:nil]; + FSTDocument *doc2 = FSTTestDoc("docs/2", 2, @{ @"value" : @2 }, NO); FSTWatchChange *change4 = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @1 ] removedTargetIDs:@[] documentKey:doc2.key document:doc2]; // We're waiting for the unwatch and watch ack - NSDictionary *pendingResponses = @{ @1 : @2 }; - - FSTWatchChangeAggregator *aggregator = - [self aggregatorWithTargets:@[ @1 ] - outstanding:pendingResponses - changes:@[ change1, change2, change3, change4 ]]; - FSTRemoteEvent *event = [aggregator remoteEvent]; + NSDictionary *outstandingResponses = @{ @1 : @2 }; + + FSTRemoteEvent *event = + [self remoteEventAtSnapshotVersion:3 + targetMap:targetMap + outstandingResponses:outstandingResponses + existingKeys:DocumentKeySet {} + changes:@[ change1, change2, change3, change4 ]]; XCTAssertEqual(event.snapshotVersion, testutil::Version(3)); // doc1 is ignored because it was part of an inactive target, but doc2 is in the changes // because it become active. XCTAssertEqual(event.documentUpdates.size(), 1); XCTAssertEqualObjects(event.documentUpdates.at(doc2.key), doc2); - XCTAssertEqual(event.targetChanges.count, 1); + XCTAssertEqual(event.targetChanges.size(), 1); } - (void)testWillIgnoreEventsForRemovedTargets { - FSTDocument *doc1 = FSTTestDoc("docs/1", 1, @{ @"value" : @1 }, NO); + NSDictionary *targetMap = [self queryDataForTargets:@[]]; + FSTDocument *doc1 = FSTTestDoc("docs/1", 1, @{ @"value" : @1 }, NO); FSTWatchChange *change1 = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @1 ] removedTargetIDs:@[] documentKey:doc1.key @@ -170,25 +292,25 @@ NS_ASSUME_NONNULL_BEGIN cause:nil]; // We're waiting for the unwatch ack - NSDictionary *pendingResponses = @{ @1 : @1 }; + NSDictionary *outstandingResponses = @{ @1 : @1 }; - FSTWatchChangeAggregator *aggregator = - [self aggregatorWithTargets:@[] outstanding:pendingResponses changes:@[ change1, change2 ]]; - - FSTRemoteEvent *event = [aggregator remoteEvent]; + FSTRemoteEvent *event = [self remoteEventAtSnapshotVersion:3 + targetMap:targetMap + outstandingResponses:outstandingResponses + existingKeys:DocumentKeySet {} + changes:@[ change1, change2 ]]; XCTAssertEqual(event.snapshotVersion, testutil::Version(3)); // doc1 is ignored because it was part of an inactive target XCTAssertEqual(event.documentUpdates.size(), 0); // Target 1 is ignored because it was removed - XCTAssertEqual(event.targetChanges.count, 0); + XCTAssertEqual(event.targetChanges.size(), 0); } - (void)testWillKeepResetMappingEvenWithUpdates { - FSTDocument *doc1 = FSTTestDoc("docs/1", 1, @{ @"value" : @1 }, NO); - FSTDocument *doc2 = FSTTestDoc("docs/2", 2, @{ @"value" : @2 }, NO); - FSTDocument *doc3 = FSTTestDoc("docs/3", 3, @{ @"value" : @3 }, NO); + NSDictionary *targetMap = [self queryDataForTargets:@[ @1 ]]; + FSTDocument *doc1 = FSTTestDoc("docs/1", 1, @{ @"value" : @1 }, NO); FSTWatchChange *change1 = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @1 ] removedTargetIDs:@[] documentKey:doc1.key @@ -199,10 +321,13 @@ NS_ASSUME_NONNULL_BEGIN cause:nil]; // Add doc2, doc3 + FSTDocument *doc2 = FSTTestDoc("docs/2", 2, @{ @"value" : @2 }, NO); FSTWatchChange *change3 = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @1 ] removedTargetIDs:@[] documentKey:doc2.key document:doc2]; + + FSTDocument *doc3 = FSTTestDoc("docs/3", 3, @{ @"value" : @3 }, NO); FSTWatchChange *change4 = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @1 ] removedTargetIDs:@[] documentKey:doc3.key @@ -213,203 +338,208 @@ NS_ASSUME_NONNULL_BEGIN removedTargetIDs:@[ @1 ] documentKey:doc2.key document:doc2]; - - FSTWatchChangeAggregator *aggregator = - [self aggregatorWithTargets:@[ @1 ] - outstanding:_noPendingResponses - changes:@[ change1, change2, change3, change4, change5 ]]; - - FSTRemoteEvent *event = [aggregator remoteEvent]; + FSTRemoteEvent *event = + [self remoteEventAtSnapshotVersion:3 + targetMap:targetMap + outstandingResponses:_noOutstandingResponses + existingKeys:DocumentKeySet{doc1.key} + changes:@[ change1, change2, change3, change4, change5 ]]; XCTAssertEqual(event.snapshotVersion, testutil::Version(3)); XCTAssertEqual(event.documentUpdates.size(), 3); XCTAssertEqualObjects(event.documentUpdates.at(doc1.key), doc1); XCTAssertEqualObjects(event.documentUpdates.at(doc2.key), doc2); XCTAssertEqualObjects(event.documentUpdates.at(doc3.key), doc3); - XCTAssertEqual(event.targetChanges.count, 1); + XCTAssertEqual(event.targetChanges.size(), 1); // Only doc3 is part of the new mapping - FSTResetMapping *expectedMapping = [FSTResetMapping mappingWithDocuments:@[ doc3 ]]; - - XCTAssertEqualObjects(event.targetChanges[@1].mapping, expectedMapping); + FSTTargetChange *expectedChange = FSTTestTargetChange( + DocumentKeySet{doc3.key}, DocumentKeySet{}, DocumentKeySet{doc1.key}, _resumeToken1, NO); + XCTAssertEqualObjects(event.targetChanges.at(1), expectedChange); } - (void)testWillHandleSingleReset { + NSDictionary *targetMap = [self queryDataForTargets:@[ @1 ]]; + // Reset target - FSTWatchChange *change = [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateReset - targetIDs:@[ @1 ] - cause:nil]; + FSTWatchTargetChange *change = + [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateReset + targetIDs:@[ @1 ] + cause:nil]; - FSTWatchChangeAggregator *aggregator = - [self aggregatorWithTargets:@[ @1 ] outstanding:_noPendingResponses changes:@[ change ]]; + FSTWatchChangeAggregator *aggregator = [self aggregatorWithTargetMap:targetMap + outstandingResponses:_noOutstandingResponses + existingKeys:DocumentKeySet {} + changes:@[]]; + [aggregator handleTargetChange:change]; + + FSTRemoteEvent *event = [aggregator remoteEventAtSnapshotVersion:testutil::Version(3)]; - FSTRemoteEvent *event = [aggregator remoteEvent]; XCTAssertEqual(event.snapshotVersion, testutil::Version(3)); XCTAssertEqual(event.documentUpdates.size(), 0); - - XCTAssertEqual(event.targetChanges.count, 1); + XCTAssertEqual(event.targetChanges.size(), 1); // Reset mapping is empty - FSTResetMapping *expectedMapping = [FSTResetMapping mappingWithDocuments:@[]]; - XCTAssertEqualObjects(event.targetChanges[@1].mapping, expectedMapping); + FSTTargetChange *expectedChange = + FSTTestTargetChange(DocumentKeySet{}, DocumentKeySet{}, DocumentKeySet{}, [NSData data], NO); + XCTAssertEqualObjects(event.targetChanges.at(1), expectedChange); } - (void)testWillHandleTargetAddAndRemovalInSameBatch { - FSTDocument *doc1a = FSTTestDoc("docs/1", 1, @{ @"value" : @1 }, NO); - FSTDocument *doc1b = FSTTestDoc("docs/1", 1, @{ @"value" : @2 }, NO); + NSDictionary *targetMap = + [self queryDataForTargets:@[ @1, @2 ]]; + FSTDocument *doc1a = FSTTestDoc("docs/1", 1, @{ @"value" : @1 }, NO); FSTWatchChange *change1 = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @1 ] removedTargetIDs:@[ @2 ] documentKey:doc1a.key document:doc1a]; + FSTDocument *doc1b = FSTTestDoc("docs/1", 1, @{ @"value" : @2 }, NO); FSTWatchChange *change2 = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @2 ] removedTargetIDs:@[ @1 ] documentKey:doc1b.key document:doc1b]; - FSTWatchChangeAggregator *aggregator = [self aggregatorWithTargets:@[ @1, @2 ] - outstanding:_noPendingResponses - changes:@[ change1, change2 ]]; - FSTRemoteEvent *event = [aggregator remoteEvent]; + FSTRemoteEvent *event = [self remoteEventAtSnapshotVersion:3 + targetMap:targetMap + outstandingResponses:_noOutstandingResponses + existingKeys:DocumentKeySet{doc1a.key} + changes:@[ change1, change2 ]]; XCTAssertEqual(event.snapshotVersion, testutil::Version(3)); XCTAssertEqual(event.documentUpdates.size(), 1); XCTAssertEqualObjects(event.documentUpdates.at(doc1b.key), doc1b); - XCTAssertEqual(event.targetChanges.count, 2); + XCTAssertEqual(event.targetChanges.size(), 2); - FSTUpdateMapping *mapping1 = - [FSTUpdateMapping mappingWithAddedDocuments:@[] removedDocuments:@[ doc1b ]]; - XCTAssertEqualObjects(event.targetChanges[@1].mapping, mapping1); + FSTTargetChange *targetChange1 = FSTTestTargetChange( + DocumentKeySet{}, DocumentKeySet{}, DocumentKeySet{doc1b.key}, _resumeToken1, NO); + XCTAssertEqualObjects(event.targetChanges.at(1), targetChange1); - FSTUpdateMapping *mapping2 = - [FSTUpdateMapping mappingWithAddedDocuments:@[ doc1b ] removedDocuments:@[]]; - XCTAssertEqualObjects(event.targetChanges[@2].mapping, mapping2); + FSTTargetChange *targetChange2 = FSTTestTargetChange(DocumentKeySet{}, DocumentKeySet{doc1b.key}, + DocumentKeySet{}, _resumeToken1, NO); + XCTAssertEqualObjects(event.targetChanges.at(2), targetChange2); } - (void)testTargetCurrentChangeWillMarkTheTargetCurrent { + NSDictionary *targetMap = [self queryDataForTargets:@[ @1 ]]; + FSTWatchChange *change = [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateCurrent targetIDs:@[ @1 ] resumeToken:_resumeToken1]; + FSTRemoteEvent *event = [self remoteEventAtSnapshotVersion:3 + targetMap:targetMap + outstandingResponses:_noOutstandingResponses + existingKeys:DocumentKeySet {} + changes:@[ change ]]; - FSTWatchChangeAggregator *aggregator = - [self aggregatorWithTargets:@[ @1 ] outstanding:_noPendingResponses changes:@[ change ]]; - - FSTRemoteEvent *event = [aggregator remoteEvent]; XCTAssertEqual(event.snapshotVersion, testutil::Version(3)); XCTAssertEqual(event.documentUpdates.size(), 0); - XCTAssertEqual(event.targetChanges.count, 1); - FSTTargetChange *targetChange = event.targetChanges[@1]; - XCTAssertEqualObjects(targetChange.mapping, [[FSTUpdateMapping alloc] init]); - XCTAssertEqual(targetChange.currentStatusUpdate, FSTCurrentStatusUpdateMarkCurrent); - XCTAssertEqualObjects(targetChange.resumeToken, _resumeToken1); + XCTAssertEqual(event.targetChanges.size(), 1); + + FSTTargetChange *targetChange = + FSTTestTargetChange(DocumentKeySet{}, DocumentKeySet{}, DocumentKeySet{}, _resumeToken1, YES); + XCTAssertEqualObjects(event.targetChanges.at(1), targetChange); } - (void)testTargetAddedChangeWillResetPreviousState { - FSTDocument *doc1 = FSTTestDoc("docs/1", 1, @{ @"value" : @1 }, NO); - FSTDocument *doc2 = FSTTestDoc("docs/2", 2, @{ @"value" : @2 }, NO); + NSDictionary *targetMap = + [self queryDataForTargets:@[ @1, @3 ]]; + FSTDocument *doc1 = FSTTestDoc("docs/1", 1, @{ @"value" : @1 }, NO); FSTWatchChange *change1 = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @1, @3 ] removedTargetIDs:@[ @2 ] documentKey:doc1.key document:doc1]; + FSTWatchChange *change2 = [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateCurrent targetIDs:@[ @1, @2, @3 ] resumeToken:_resumeToken1]; + FSTWatchChange *change3 = [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateRemoved targetIDs:@[ @1 ] cause:nil]; + FSTWatchChange *change4 = [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateRemoved targetIDs:@[ @2 ] cause:nil]; + FSTWatchChange *change5 = [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateAdded targetIDs:@[ @1 ] cause:nil]; + + FSTDocument *doc2 = FSTTestDoc("docs/2", 2, @{ @"value" : @2 }, NO); FSTWatchChange *change6 = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @1 ] removedTargetIDs:@[ @3 ] documentKey:doc2.key document:doc2]; - NSDictionary *pendingResponses = @{ @1 : @2, @2 : @1 }; + NSDictionary *outstandingResponses = @{ @1 : @2, @2 : @1 }; - FSTWatchChangeAggregator *aggregator = - [self aggregatorWithTargets:@[ @1, @3 ] - outstanding:pendingResponses - changes:@[ change1, change2, change3, change4, change5, change6 ]]; + FSTRemoteEvent *event = + [self remoteEventAtSnapshotVersion:3 + targetMap:targetMap + outstandingResponses:outstandingResponses + existingKeys:DocumentKeySet{doc2.key} + changes:@[ change1, change2, change3, change4, change5, change6 ]]; - FSTRemoteEvent *event = [aggregator remoteEvent]; XCTAssertEqual(event.snapshotVersion, testutil::Version(3)); XCTAssertEqual(event.documentUpdates.size(), 2); XCTAssertEqualObjects(event.documentUpdates.at(doc1.key), doc1); XCTAssertEqualObjects(event.documentUpdates.at(doc2.key), doc2); // target 1 and 3 are affected (1 because of re-add), target 2 is not because of remove - XCTAssertEqual(event.targetChanges.count, 2); + XCTAssertEqual(event.targetChanges.size(), 2); - // doc1 was before the remove, so it does not show up in the mapping - FSTUpdateMapping *mapping1 = - [FSTUpdateMapping mappingWithAddedDocuments:@[ doc2 ] removedDocuments:@[]]; - XCTAssertEqualObjects(event.targetChanges[@1].mapping, mapping1); - // Current was before the remove - XCTAssertEqual(event.targetChanges[@1].currentStatusUpdate, FSTCurrentStatusUpdateNone); + // doc1 was before the remove, so it does not show up in the mapping. + // Current was before the remove. + FSTTargetChange *targetChange1 = FSTTestTargetChange(DocumentKeySet{}, DocumentKeySet{doc2.key}, + DocumentKeySet{}, _resumeToken1, NO); + XCTAssertEqualObjects(event.targetChanges.at(1), targetChange1); // Doc1 was before the remove - FSTUpdateMapping *mapping3 = - [FSTUpdateMapping mappingWithAddedDocuments:@[ doc1 ] removedDocuments:@[ doc2 ]]; - XCTAssertEqualObjects(event.targetChanges[@3].mapping, mapping3); // Current was before the remove - XCTAssertEqual(event.targetChanges[@3].currentStatusUpdate, FSTCurrentStatusUpdateMarkCurrent); - XCTAssertEqualObjects(event.targetChanges[@3].resumeToken, _resumeToken1); + FSTTargetChange *targetChange3 = FSTTestTargetChange( + DocumentKeySet{doc1.key}, DocumentKeySet{}, DocumentKeySet{doc2.key}, _resumeToken1, YES); + XCTAssertEqualObjects(event.targetChanges.at(3), targetChange3); } - (void)testNoChangeWillStillMarkTheAffectedTargets { - FSTWatchChange *change = [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateNoChange - targetIDs:@[ @1 ] - resumeToken:_resumeToken1]; + NSDictionary *targetMap = [self queryDataForTargets:@[ @1 ]]; - FSTWatchChangeAggregator *aggregator = - [self aggregatorWithTargets:@[ @1 ] outstanding:_noPendingResponses changes:@[ change ]]; - - FSTRemoteEvent *event = [aggregator remoteEvent]; - XCTAssertEqual(event.snapshotVersion, testutil::Version(3)); - XCTAssertEqual(event.documentUpdates.size(), 0); - XCTAssertEqual(event.targetChanges.count, 1); - XCTAssertEqualObjects(event.targetChanges[@1].mapping, [[FSTUpdateMapping alloc] init]); - XCTAssertEqual(event.targetChanges[@1].currentStatusUpdate, FSTCurrentStatusUpdateNone); - XCTAssertEqualObjects(event.targetChanges[@1].resumeToken, _resumeToken1); -} + FSTWatchChangeAggregator *aggregator = [self aggregatorWithTargetMap:targetMap + outstandingResponses:_noOutstandingResponses + existingKeys:DocumentKeySet {} + changes:@[]]; -- (void)testExistenceFiltersWillReplacePreviousExistenceFilters { - FSTExistenceFilter *filter1 = [FSTExistenceFilter filterWithCount:1]; - FSTExistenceFilter *filter2 = [FSTExistenceFilter filterWithCount:2]; - FSTWatchChange *change1 = [FSTExistenceFilterWatchChange changeWithFilter:filter1 targetID:1]; - FSTWatchChange *change2 = [FSTExistenceFilterWatchChange changeWithFilter:filter1 targetID:2]; - // replace filter1 for target 2 - FSTWatchChange *change3 = [FSTExistenceFilterWatchChange changeWithFilter:filter2 targetID:2]; + FSTWatchTargetChange *change = + [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateNoChange + targetIDs:@[ @1 ] + resumeToken:_resumeToken1]; + [aggregator handleTargetChange:change]; - FSTWatchChangeAggregator *aggregator = - [self aggregatorWithTargets:@[ @1, @2 ] - outstanding:_noPendingResponses - changes:@[ change1, change2, change3 ]]; + FSTRemoteEvent *event = [aggregator remoteEventAtSnapshotVersion:testutil::Version(3)]; - FSTRemoteEvent *event = [aggregator remoteEvent]; XCTAssertEqual(event.snapshotVersion, testutil::Version(3)); XCTAssertEqual(event.documentUpdates.size(), 0); - XCTAssertEqual(event.targetChanges.count, 0); - XCTAssertEqual(aggregator.existenceFilters.count, 2); - XCTAssertEqual(aggregator.existenceFilters[@1], filter1); - XCTAssertEqual(aggregator.existenceFilters[@2], filter2); + XCTAssertEqual(event.targetChanges.size(), 1); + + FSTTargetChange *targetChange = + FSTTestTargetChange(DocumentKeySet{}, DocumentKeySet{}, DocumentKeySet{}, _resumeToken1, NO); + XCTAssertEqualObjects(event.targetChanges.at(1), targetChange); } -- (void)testExistenceFilterMismatchResetsTarget { - FSTDocument *doc1 = FSTTestDoc("docs/1", 1, @{ @"value" : @1 }, NO); - FSTDocument *doc2 = FSTTestDoc("docs/2", 2, @{ @"value" : @2 }, NO); +- (void)testExistenceFilterMismatchClearsTarget { + NSDictionary *targetMap = + [self queryDataForTargets:@[ @1, @2 ]]; + FSTDocument *doc1 = FSTTestDoc("docs/1", 1, @{ @"value" : @1 }, NO); FSTWatchChange *change1 = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @1 ] removedTargetIDs:@[] documentKey:doc1.key document:doc1]; + FSTDocument *doc2 = FSTTestDoc("docs/2", 2, @{ @"value" : @2 }, NO); FSTWatchChange *change2 = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @1 ] removedTargetIDs:@[] documentKey:doc2.key @@ -420,270 +550,353 @@ NS_ASSUME_NONNULL_BEGIN resumeToken:_resumeToken1]; FSTWatchChangeAggregator *aggregator = - [self aggregatorWithTargets:@[ @1 ] - outstanding:_noPendingResponses - changes:@[ change1, change2, change3 ]]; + [self aggregatorWithTargetMap:targetMap + outstandingResponses:_noOutstandingResponses + existingKeys:DocumentKeySet{doc1.key, doc2.key} + changes:@[ change1, change2, change3 ]]; + + FSTRemoteEvent *event = [aggregator remoteEventAtSnapshotVersion:testutil::Version(3)]; - FSTRemoteEvent *event = [aggregator remoteEvent]; XCTAssertEqual(event.snapshotVersion, testutil::Version(3)); XCTAssertEqual(event.documentUpdates.size(), 2); XCTAssertEqualObjects(event.documentUpdates.at(doc1.key), doc1); XCTAssertEqualObjects(event.documentUpdates.at(doc2.key), doc2); - XCTAssertEqual(event.targetChanges.count, 1); + XCTAssertEqual(event.targetChanges.size(), 2); + + FSTTargetChange *targetChange1 = FSTTestTargetChange( + DocumentKeySet{}, DocumentKeySet{doc1.key, doc2.key}, DocumentKeySet{}, _resumeToken1, YES); + XCTAssertEqualObjects(event.targetChanges.at(1), targetChange1); + + FSTTargetChange *targetChange2 = + FSTTestTargetChange(DocumentKeySet{}, DocumentKeySet{}, DocumentKeySet{}, _resumeToken1, NO); + XCTAssertEqualObjects(event.targetChanges.at(2), targetChange2); + + // The existence filter mismatch will remove the document from target 1, + // but not synthesize a document delete. + FSTExistenceFilterWatchChange *change4 = + [FSTExistenceFilterWatchChange changeWithFilter:[FSTExistenceFilter filterWithCount:1] + targetID:1]; + [aggregator handleExistenceFilter:change4]; - FSTUpdateMapping *mapping1 = - [FSTUpdateMapping mappingWithAddedDocuments:@[ doc1, doc2 ] removedDocuments:@[]]; - XCTAssertEqualObjects(event.targetChanges[@1].mapping, mapping1); - XCTAssertEqual(event.targetChanges[@1].snapshotVersion, testutil::Version(3)); - XCTAssertEqual(event.targetChanges[@1].currentStatusUpdate, FSTCurrentStatusUpdateMarkCurrent); - XCTAssertEqualObjects(event.targetChanges[@1].resumeToken, _resumeToken1); + event = [aggregator remoteEventAtSnapshotVersion:testutil::Version(4)]; - [event handleExistenceFilterMismatchForTargetID:@1]; + FSTTargetChange *targetChange3 = FSTTestTargetChange( + DocumentKeySet{}, DocumentKeySet{}, DocumentKeySet{doc1.key, doc2.key}, [NSData data], NO); + XCTAssertEqualObjects(event.targetChanges.at(1), targetChange3); - // Mapping is reset - XCTAssertEqualObjects(event.targetChanges[@1].mapping, [[FSTResetMapping alloc] init]); - // Reset the resume snapshot - XCTAssertEqual(event.targetChanges[@1].snapshotVersion, testutil::Version(0)); - // Target needs to be set to not current - XCTAssertEqual(event.targetChanges[@1].currentStatusUpdate, FSTCurrentStatusUpdateMarkNotCurrent); - XCTAssertEqual(event.targetChanges[@1].resumeToken.length, 0); + XCTAssertEqual(event.targetChanges.size(), 1); + XCTAssertEqual(event.targetMismatches.size(), 1); + XCTAssertEqual(event.documentUpdates.size(), 0); } -- (void)testDocumentUpdate { +- (void)testExistenceFilterMismatchRemovesCurrentChanges { + NSDictionary *targetMap = [self queryDataForTargets:@[ @1 ]]; + + FSTWatchChangeAggregator *aggregator = [self aggregatorWithTargetMap:targetMap + outstandingResponses:_noOutstandingResponses + existingKeys:DocumentKeySet {} + changes:@[]]; + + FSTWatchTargetChange *markCurrent = + [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateCurrent + targetIDs:@[ @1 ] + resumeToken:_resumeToken1]; + [aggregator handleTargetChange:markCurrent]; + FSTDocument *doc1 = FSTTestDoc("docs/1", 1, @{ @"value" : @1 }, NO); - FSTDeletedDocument *deletedDoc1 = - [FSTDeletedDocument documentWithKey:doc1.key version:testutil::Version(3)]; - FSTDocument *doc2 = FSTTestDoc("docs/2", 2, @{ @"value" : @2 }, NO); - FSTDocument *doc3 = FSTTestDoc("docs/3", 3, @{ @"value" : @3 }, NO); + FSTDocumentWatchChange *addDoc = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @1 ] + removedTargetIDs:@[] + documentKey:doc1.key + document:doc1]; + [aggregator handleDocumentChange:addDoc]; + + // The existence filter mismatch will remove the document from target 1, but not synthesize a + // document delete. + FSTExistenceFilterWatchChange *existenceFilter = + [FSTExistenceFilterWatchChange changeWithFilter:[FSTExistenceFilter filterWithCount:0] + targetID:1]; + [aggregator handleExistenceFilter:existenceFilter]; + + FSTRemoteEvent *event = [aggregator remoteEventAtSnapshotVersion:testutil::Version(3)]; + XCTAssertEqual(event.snapshotVersion, testutil::Version(3)); + XCTAssertEqual(event.documentUpdates.size(), 1); + XCTAssertEqual(event.targetMismatches.size(), 1); + XCTAssertEqualObjects(event.documentUpdates.at(doc1.key), doc1); + + XCTAssertEqual(event.targetChanges.size(), 1); + + FSTTargetChange *targetChange1 = + FSTTestTargetChange(DocumentKeySet{}, DocumentKeySet{}, DocumentKeySet{}, [NSData data], NO); + XCTAssertEqualObjects(event.targetChanges.at(1), targetChange1); +} + +- (void)testDocumentUpdate { + NSDictionary *targetMap = [self queryDataForTargets:@[ @1 ]]; + + FSTDocument *doc1 = FSTTestDoc("docs/1", 1, @{ @"value" : @1 }, NO); FSTWatchChange *change1 = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @1 ] removedTargetIDs:@[] documentKey:doc1.key document:doc1]; + FSTDocument *doc2 = FSTTestDoc("docs/2", 2, @{ @"value" : @2 }, NO); FSTWatchChange *change2 = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @1 ] removedTargetIDs:@[] documentKey:doc2.key document:doc2]; - FSTWatchChangeAggregator *aggregator = [self aggregatorWithTargets:@[ @1 ] - outstanding:_noPendingResponses - changes:@[ change1, change2 ]]; + FSTWatchChangeAggregator *aggregator = [self aggregatorWithTargetMap:targetMap + outstandingResponses:_noOutstandingResponses + existingKeys:DocumentKeySet {} + changes:@[ change1, change2 ]]; + + FSTRemoteEvent *event = [aggregator remoteEventAtSnapshotVersion:testutil::Version(3)]; - FSTRemoteEvent *event = [aggregator remoteEvent]; XCTAssertEqual(event.snapshotVersion, testutil::Version(3)); XCTAssertEqual(event.documentUpdates.size(), 2); XCTAssertEqualObjects(event.documentUpdates.at(doc1.key), doc1); XCTAssertEqualObjects(event.documentUpdates.at(doc2.key), doc2); - // Update doc1 - [event addDocumentUpdate:deletedDoc1]; - [event addDocumentUpdate:doc3]; + [_targetMetadataProvider setSyncedKeys:DocumentKeySet{doc1.key, doc2.key} + forQueryData:targetMap[@1]]; + + FSTDeletedDocument *deletedDoc1 = + [FSTDeletedDocument documentWithKey:doc1.key version:testutil::Version(3)]; + FSTDocumentWatchChange *change3 = + [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[] + removedTargetIDs:@[ @1 ] + documentKey:deletedDoc1.key + document:deletedDoc1]; + [aggregator handleDocumentChange:change3]; + + FSTDocument *updatedDoc2 = FSTTestDoc("docs/2", 3, @{ @"value" : @2 }, NO); + FSTDocumentWatchChange *change4 = + [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @1 ] + removedTargetIDs:@[] + documentKey:updatedDoc2.key + document:updatedDoc2]; + [aggregator handleDocumentChange:change4]; + + FSTDocument *doc3 = FSTTestDoc("docs/3", 3, @{ @"value" : @3 }, NO); + FSTDocumentWatchChange *change5 = + [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @1 ] + removedTargetIDs:@[] + documentKey:doc3.key + document:doc3]; + [aggregator handleDocumentChange:change5]; + + event = [aggregator remoteEventAtSnapshotVersion:testutil::Version(3)]; XCTAssertEqual(event.snapshotVersion, testutil::Version(3)); XCTAssertEqual(event.documentUpdates.size(), 3); // doc1 is replaced XCTAssertEqualObjects(event.documentUpdates.at(doc1.key), deletedDoc1); - // doc2 is untouched - XCTAssertEqualObjects(event.documentUpdates.at(doc2.key), doc2); + // doc2 is updated + XCTAssertEqualObjects(event.documentUpdates.at(doc2.key), updatedDoc2); // doc3 is new XCTAssertEqualObjects(event.documentUpdates.at(doc3.key), doc3); // Target is unchanged - XCTAssertEqual(event.targetChanges.count, 1); + XCTAssertEqual(event.targetChanges.size(), 1); - FSTUpdateMapping *mapping1 = - [FSTUpdateMapping mappingWithAddedDocuments:@[ doc1, doc2 ] removedDocuments:@[]]; - XCTAssertEqualObjects(event.targetChanges[@1].mapping, mapping1); + FSTTargetChange *targetChange = + FSTTestTargetChange(DocumentKeySet{doc3.key}, DocumentKeySet{updatedDoc2.key}, + DocumentKeySet{deletedDoc1.key}, _resumeToken1, NO); + XCTAssertEqualObjects(event.targetChanges.at(1), targetChange); } - (void)testResumeTokensHandledPerTarget { + NSDictionary *targetMap = + [self queryDataForTargets:@[ @1, @2 ]]; + + FSTWatchChangeAggregator *aggregator = [self aggregatorWithTargetMap:targetMap + outstandingResponses:_noOutstandingResponses + existingKeys:DocumentKeySet {} + changes:@[]]; + + FSTWatchTargetChange *change1 = + [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateCurrent + targetIDs:@[ @1 ] + resumeToken:_resumeToken1]; + [aggregator handleTargetChange:change1]; + NSData *resumeToken2 = [@"resume2" dataUsingEncoding:NSUTF8StringEncoding]; - FSTWatchChange *change1 = [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateCurrent - targetIDs:@[ @1 ] - resumeToken:_resumeToken1]; - FSTWatchChange *change2 = [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateCurrent - targetIDs:@[ @2 ] - resumeToken:resumeToken2]; - FSTWatchChangeAggregator *aggregator = [self aggregatorWithTargets:@[ @1, @2 ] - outstanding:_noPendingResponses - changes:@[ change1, change2 ]]; - - FSTRemoteEvent *event = [aggregator remoteEvent]; - XCTAssertEqual(event.targetChanges.count, 2); - - FSTUpdateMapping *mapping1 = - [FSTUpdateMapping mappingWithAddedDocuments:@[] removedDocuments:@[]]; - XCTAssertEqualObjects(event.targetChanges[@1].mapping, mapping1); - XCTAssertEqual(event.targetChanges[@1].snapshotVersion, testutil::Version(3)); - XCTAssertEqual(event.targetChanges[@1].currentStatusUpdate, FSTCurrentStatusUpdateMarkCurrent); - XCTAssertEqualObjects(event.targetChanges[@1].resumeToken, _resumeToken1); - - XCTAssertEqualObjects(event.targetChanges[@2].mapping, mapping1); - XCTAssertEqual(event.targetChanges[@2].snapshotVersion, testutil::Version(3)); - XCTAssertEqual(event.targetChanges[@2].currentStatusUpdate, FSTCurrentStatusUpdateMarkCurrent); - XCTAssertEqualObjects(event.targetChanges[@2].resumeToken, resumeToken2); + FSTWatchTargetChange *change2 = + [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateCurrent + targetIDs:@[ @2 ] + resumeToken:resumeToken2]; + [aggregator handleTargetChange:change2]; + + FSTRemoteEvent *event = [aggregator remoteEventAtSnapshotVersion:testutil::Version(3)]; + XCTAssertEqual(event.targetChanges.size(), 2); + + FSTTargetChange *targetChange1 = + FSTTestTargetChange(DocumentKeySet{}, DocumentKeySet{}, DocumentKeySet{}, _resumeToken1, YES); + XCTAssertEqualObjects(event.targetChanges.at(1), targetChange1); + + FSTTargetChange *targetChange2 = + FSTTestTargetChange(DocumentKeySet{}, DocumentKeySet{}, DocumentKeySet{}, resumeToken2, YES); + XCTAssertEqualObjects(event.targetChanges.at(2), targetChange2); } - (void)testLastResumeTokenWins { + NSDictionary *targetMap = + [self queryDataForTargets:@[ @1, @2 ]]; + + FSTWatchChangeAggregator *aggregator = [self aggregatorWithTargetMap:targetMap + outstandingResponses:_noOutstandingResponses + existingKeys:DocumentKeySet {} + changes:@[]]; + + FSTWatchTargetChange *change1 = + [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateCurrent + targetIDs:@[ @1 ] + resumeToken:_resumeToken1]; + [aggregator handleTargetChange:change1]; + NSData *resumeToken2 = [@"resume2" dataUsingEncoding:NSUTF8StringEncoding]; + FSTWatchTargetChange *change2 = + [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateNoChange + targetIDs:@[ @1 ] + resumeToken:resumeToken2]; + [aggregator handleTargetChange:change2]; + NSData *resumeToken3 = [@"resume3" dataUsingEncoding:NSUTF8StringEncoding]; + FSTWatchTargetChange *change3 = + [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateNoChange + targetIDs:@[ @2 ] + resumeToken:resumeToken3]; + [aggregator handleTargetChange:change3]; - FSTWatchChange *change1 = [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateCurrent - targetIDs:@[ @1 ] - resumeToken:_resumeToken1]; - FSTWatchChange *change2 = [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateReset - targetIDs:@[ @1 ] - resumeToken:resumeToken2]; - FSTWatchChange *change3 = [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateReset - targetIDs:@[ @2 ] - resumeToken:resumeToken3]; - FSTWatchChangeAggregator *aggregator = - [self aggregatorWithTargets:@[ @1, @2 ] - outstanding:_noPendingResponses - changes:@[ change1, change2, change3 ]]; - - FSTRemoteEvent *event = [aggregator remoteEvent]; - XCTAssertEqual(event.targetChanges.count, 2); - - FSTResetMapping *mapping1 = [FSTResetMapping mappingWithDocuments:@[]]; - XCTAssertEqualObjects(event.targetChanges[@1].mapping, mapping1); - XCTAssertEqual(event.targetChanges[@1].snapshotVersion, testutil::Version(3)); - XCTAssertEqual(event.targetChanges[@1].currentStatusUpdate, FSTCurrentStatusUpdateMarkCurrent); - XCTAssertEqualObjects(event.targetChanges[@1].resumeToken, resumeToken2); - - XCTAssertEqualObjects(event.targetChanges[@2].mapping, mapping1); - XCTAssertEqual(event.targetChanges[@2].snapshotVersion, testutil::Version(3)); - XCTAssertEqual(event.targetChanges[@2].currentStatusUpdate, FSTCurrentStatusUpdateNone); - XCTAssertEqualObjects(event.targetChanges[@2].resumeToken, resumeToken3); + FSTRemoteEvent *event = [aggregator remoteEventAtSnapshotVersion:testutil::Version(3)]; + XCTAssertEqual(event.targetChanges.size(), 2); + + FSTTargetChange *targetChange1 = + FSTTestTargetChange(DocumentKeySet{}, DocumentKeySet{}, DocumentKeySet{}, resumeToken2, YES); + XCTAssertEqualObjects(event.targetChanges.at(1), targetChange1); + + FSTTargetChange *targetChange2 = + FSTTestTargetChange(DocumentKeySet{}, DocumentKeySet{}, DocumentKeySet{}, resumeToken3, NO); + XCTAssertEqualObjects(event.targetChanges.at(2), targetChange2); } - (void)testSynthesizeDeletes { - FSTWatchChange *shouldSynthesize = - [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateCurrent targetIDs:@[ @1 ]]; + NSDictionary *targetMap = + [self queryDataForLimboTargets:@[ @1 ]]; - FSTWatchChangeAggregator *aggregator = [self aggregatorWithTargets:@[ @1 ] - outstanding:_noPendingResponses - changes:@[ shouldSynthesize ]]; + DocumentKey limboKey = testutil::Key("coll/limbo"); - FSTRemoteEvent *event = [aggregator remoteEvent]; - DocumentKey synthesized = DocumentKey::FromPathString("docs/2"); - XCTAssertEqual(event.documentUpdates.find(synthesized), event.documentUpdates.end()); + FSTWatchChange *resolveLimboTarget = + [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateCurrent targetIDs:@[ @1 ]]; + + FSTRemoteEvent *event = [self remoteEventAtSnapshotVersion:3 + targetMap:targetMap + outstandingResponses:_noOutstandingResponses + existingKeys:DocumentKeySet {} + changes:@[ resolveLimboTarget ]]; - FSTTargetChange *limboTargetChange = event.targetChanges[@1]; - [event synthesizeDeleteForLimboTargetChange:limboTargetChange key:synthesized]; FSTDeletedDocument *expected = - [FSTDeletedDocument documentWithKey:synthesized version:event.snapshotVersion]; - XCTAssertEqualObjects(expected, event.documentUpdates.at(synthesized)); - XCTAssertTrue(event.limboDocumentChanges.contains(synthesized)); + [FSTDeletedDocument documentWithKey:limboKey version:event.snapshotVersion]; + XCTAssertEqualObjects(event.documentUpdates.at(limboKey), expected); + XCTAssertTrue(event.limboDocumentChanges.contains(limboKey)); } - (void)testDoesntSynthesizeDeletesForWrongState { - FSTWatchChange *wrongState = - [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateNoChange targetIDs:@[ @2 ]]; + NSDictionary *targetMap = + [self queryDataForLimboTargets:@[ @1 ]]; - FSTWatchChangeAggregator *aggregator = - [self aggregatorWithTargets:@[ @2 ] outstanding:_noPendingResponses changes:@[ wrongState ]]; + FSTWatchChange *wrongState = + [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateNoChange targetIDs:@[ @1 ]]; - FSTRemoteEvent *event = [aggregator remoteEvent]; + FSTRemoteEvent *event = [self remoteEventAtSnapshotVersion:3 + targetMap:targetMap + outstandingResponses:_noOutstandingResponses + existingKeys:DocumentKeySet {} + changes:@[ wrongState ]]; - DocumentKey notSynthesized = DocumentKey::FromPathString("docs/no1"); - [event synthesizeDeleteForLimboTargetChange:event.targetChanges[@2] key:notSynthesized]; - XCTAssertEqual(event.documentUpdates.find(notSynthesized), event.documentUpdates.end()); - XCTAssertFalse(event.limboDocumentChanges.contains(notSynthesized)); + XCTAssertEqual(event.documentUpdates.size(), 0); + XCTAssertEqual(event.limboDocumentChanges.size(), 0); } - (void)testDoesntSynthesizeDeletesForExistingDoc { + NSDictionary *targetMap = + [self queryDataForLimboTargets:@[ @3 ]]; + FSTWatchChange *hasDocument = [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateCurrent targetIDs:@[ @3 ]]; - FSTDocument *doc = FSTTestDoc("docs/1", 1, @{ @"value" : @1 }, NO); - FSTWatchChange *docChange = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @3 ] - removedTargetIDs:@[] - documentKey:doc.key - document:doc]; - FSTWatchChangeAggregator *aggregator = [self aggregatorWithTargets:@[ @3 ] - outstanding:_noPendingResponses - changes:@[ hasDocument, docChange ]]; - - FSTRemoteEvent *event = [aggregator remoteEvent]; - [event synthesizeDeleteForLimboTargetChange:event.targetChanges[@3] key:doc.key]; - FSTMaybeDocument *docData = event.documentUpdates.at(doc.key); - XCTAssertFalse([docData isKindOfClass:[FSTDeletedDocument class]]); - XCTAssertFalse(event.limboDocumentChanges.contains(doc.key)); + + FSTRemoteEvent *event = + [self remoteEventAtSnapshotVersion:3 + targetMap:targetMap + outstandingResponses:_noOutstandingResponses + existingKeys:DocumentKeySet{FSTTestDocKey(@"coll/limbo")} + changes:@[ hasDocument ]]; + + XCTAssertEqual(event.documentUpdates.size(), 0); + XCTAssertEqual(event.limboDocumentChanges.size(), 0); } -- (void)testFilterUpdates { +- (void)testSeparatesDocumentUpdates { + NSDictionary *targetMap = + [self queryDataForLimboTargets:@[ @1 ]]; + FSTDocument *newDoc = FSTTestDoc("docs/new", 1, @{@"key" : @"value"}, NO); - FSTDocument *existingDoc = FSTTestDoc("docs/existing", 1, @{@"some" : @"data"}, NO); FSTWatchChange *newDocChange = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @1 ] removedTargetIDs:@[] documentKey:newDoc.key document:newDoc]; + FSTDocument *existingDoc = FSTTestDoc("docs/existing", 1, @{@"some" : @"data"}, NO); FSTWatchChange *existingDocChange = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @1 ] removedTargetIDs:@[] documentKey:existingDoc.key document:existingDoc]; - FSTWatchChangeAggregator *aggregator = - [self aggregatorWithTargets:@[ @1 ] - outstanding:_noPendingResponses - changes:@[ newDocChange, existingDocChange ]]; - FSTRemoteEvent *event = [aggregator remoteEvent]; - DocumentKeySet existingKeys = DocumentKeySet{existingDoc.key}; - - FSTTargetChange *updateChange = event.targetChanges[@1]; - XCTAssertTrue([updateChange.mapping isKindOfClass:[FSTUpdateMapping class]]); - FSTUpdateMapping *update = (FSTUpdateMapping *)updateChange.mapping; - FSTDocumentKey *existingDocKey = existingDoc.key; - FSTDocumentKey *newDocKey = newDoc.key; - XCTAssertTrue(update.addedDocuments.contains(existingDocKey)); - - [update filterUpdatesUsingExistingKeys:existingKeys]; - // Now it's been filtered, since it already existed. - XCTAssertFalse(update.addedDocuments.contains(existingDocKey)); - XCTAssertTrue(update.addedDocuments.contains(newDocKey)); -} - -- (void)testDoesntFilterResets { - FSTDocument *existingDoc = FSTTestDoc("docs/existing", 1, @{@"some" : @"data"}, NO); - const DocumentKey &existingDocKey = existingDoc.key; - FSTWatchTargetChange *resetTargetChange = - [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateReset - targetIDs:@[ @2 ] - resumeToken:_resumeToken1]; - FSTWatchChange *existingDocChange = - [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @2 ] - removedTargetIDs:@[] - documentKey:existingDocKey - document:existingDoc]; - FSTWatchChangeAggregator *aggregator = - [self aggregatorWithTargets:@[ @2 ] - outstanding:_noPendingResponses - changes:@[ resetTargetChange, existingDocChange ]]; - FSTRemoteEvent *event = [aggregator remoteEvent]; - DocumentKeySet existingKeys = DocumentKeySet{existingDocKey}; - - FSTTargetChange *resetChange = event.targetChanges[@2]; - XCTAssertTrue([resetChange.mapping isKindOfClass:[FSTResetMapping class]]); - FSTResetMapping *resetMapping = (FSTResetMapping *)resetChange.mapping; - XCTAssertTrue(resetMapping.documents.contains(existingDocKey)); - - [resetMapping filterUpdatesUsingExistingKeys:existingKeys]; - // Document is still there, even though it already exists. Reset mappings don't get filtered. - XCTAssertTrue(resetMapping.documents.contains(existingDocKey)); + FSTDeletedDocument *deletedDoc = FSTTestDeletedDoc("docs/deleted", 1); + FSTWatchChange *deletedDocChange = + [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[] + removedTargetIDs:@[ @1 ] + documentKey:deletedDoc.key + document:deletedDoc]; + + FSTDeletedDocument *missingDoc = FSTTestDeletedDoc("docs/missing", 1); + FSTWatchChange *missingDocChange = + [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[] + removedTargetIDs:@[ @1 ] + documentKey:missingDoc.key + document:missingDoc]; + + FSTRemoteEvent *event = [self + remoteEventAtSnapshotVersion:3 + targetMap:targetMap + outstandingResponses:_noOutstandingResponses + existingKeys:DocumentKeySet{existingDoc.key, deletedDoc.key} + changes:@[ + newDocChange, existingDocChange, deletedDocChange, missingDocChange + ]]; + + FSTTargetChange *targetChange = + FSTTestTargetChange(DocumentKeySet{newDoc.key}, DocumentKeySet{existingDoc.key}, + DocumentKeySet{deletedDoc.key}, _resumeToken1, NO); + + XCTAssertEqualObjects(event.targetChanges.at(1), targetChange); } - (void)testTracksLimboDocuments { + NSMutableDictionary *targetMap = + [NSMutableDictionary dictionary]; + [targetMap addEntriesFromDictionary:[self queryDataForTargets:@[ @1 ]]]; + [targetMap addEntriesFromDictionary:[self queryDataForLimboTargets:@[ @2 ]]]; + // Add 3 docs: 1 is limbo and non-limbo, 2 is limbo-only, 3 is non-limbo FSTDocument *doc1 = FSTTestDoc("docs/1", 1, @{@"key" : @"value"}, NO); FSTDocument *doc2 = FSTTestDoc("docs/2", 1, @{@"key" : @"value"}, NO); FSTDocument *doc3 = FSTTestDoc("docs/3", 1, @{@"key" : @"value"}, NO); // Target 2 is a limbo target - FSTWatchChange *docChange1 = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @1, @2 ] removedTargetIDs:@[] documentKey:doc1.key @@ -702,20 +915,13 @@ NS_ASSUME_NONNULL_BEGIN FSTWatchChange *targetsChange = [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateCurrent targetIDs:@[ @1, @2 ]]; - NSMutableDictionary *listens = [NSMutableDictionary dictionary]; - listens[@1] = [FSTQueryData alloc]; - listens[@2] = [[FSTQueryData alloc] initWithQuery:[FSTQuery alloc] - targetID:2 - listenSequenceNumber:1000 - purpose:FSTQueryPurposeLimboResolution]; - FSTWatchChangeAggregator *aggregator = - [[FSTWatchChangeAggregator alloc] initWithSnapshotVersion:testutil::Version(3) - listenTargets:listens - pendingTargetResponses:@{}]; - - [aggregator addWatchChanges:@[ docChange1, docChange2, docChange3, targetsChange ]]; + FSTRemoteEvent *event = + [self remoteEventAtSnapshotVersion:3 + targetMap:targetMap + outstandingResponses:_noOutstandingResponses + existingKeys:DocumentKeySet {} + changes:@[ docChange1, docChange2, docChange3, targetsChange ]]; - FSTRemoteEvent *event = [aggregator remoteEvent]; DocumentKeySet limboDocChanges = event.limboDocumentChanges; // Doc1 is in both limbo and non-limbo targets, therefore not tracked as limbo XCTAssertFalse(limboDocChanges.contains(doc1.key)); diff --git a/Firestore/Example/Tests/SpecTests/FSTSpecTests.mm b/Firestore/Example/Tests/SpecTests/FSTSpecTests.mm index 77010e5..c131f7e 100644 --- a/Firestore/Example/Tests/SpecTests/FSTSpecTests.mm +++ b/Firestore/Example/Tests/SpecTests/FSTSpecTests.mm @@ -256,12 +256,15 @@ static NSString *const kNoIOSTag = @"no-ios"; } else if (watchEntity[@"doc"]) { NSArray *docSpec = watchEntity[@"doc"]; FSTDocumentKey *key = FSTTestDocKey(docSpec[0]); - FSTObjectValue *value = FSTTestObjectValue(docSpec[2]); + FSTObjectValue *_Nullable value = + [docSpec[2] isKindOfClass:[NSNull class]] ? nil : FSTTestObjectValue(docSpec[2]); SnapshotVersion version = [self parseVersion:docSpec[1]]; - FSTMaybeDocument *doc = [FSTDocument documentWithData:value - key:key - version:std::move(version) - hasLocalMutations:NO]; + FSTMaybeDocument *doc = + value ? [FSTDocument documentWithData:value + key:key + version:std::move(version) + hasLocalMutations:NO] + : [FSTDeletedDocument documentWithKey:key version:std::move(version)]; FSTWatchChange *change = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:watchEntity[@"targets"] removedTargetIDs:watchEntity[@"removedTargets"] diff --git a/Firestore/Example/Tests/SpecTests/json/existence_filter_spec_test.json b/Firestore/Example/Tests/SpecTests/json/existence_filter_spec_test.json index abd2cf4..3e5d4fb 100644 --- a/Firestore/Example/Tests/SpecTests/json/existence_filter_spec_test.json +++ b/Firestore/Example/Tests/SpecTests/json/existence_filter_spec_test.json @@ -1,7 +1,699 @@ { + "Existence filter match": { + "describeName": "Existence Filters:", + "itName": "Existence filter match", + "tags": [], + "config": { + "useGarbageCollection": true + }, + "steps": [ + { + "userListen": [ + 2, + { + "path": "collection", + "filters": [], + "orderBys": [] + } + ], + "stateExpect": { + "activeTargets": { + "2": { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "resumeToken": "" + } + } + } + }, + { + "watchAck": [ + 2 + ] + }, + { + "watchEntity": { + "docs": [ + [ + "collection/1", + 1000, + { + "v": 1 + } + ] + ], + "targets": [ + 2 + ] + } + }, + { + "watchCurrent": [ + [ + 2 + ], + "resume-token-1000" + ], + "watchSnapshot": 1000, + "expect": [ + { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "added": [ + [ + "collection/1", + 1000, + { + "v": 1 + } + ] + ], + "errorCode": 0, + "fromCache": false, + "hasPendingWrites": false + } + ] + }, + { + "watchFilter": [ + [ + 2 + ], + "collection/1" + ], + "watchSnapshot": 2000 + } + ] + }, + "Existence filter match after pending update": { + "describeName": "Existence Filters:", + "itName": "Existence filter match after pending update", + "tags": [], + "config": { + "useGarbageCollection": true + }, + "steps": [ + { + "userListen": [ + 2, + { + "path": "collection", + "filters": [], + "orderBys": [] + } + ], + "stateExpect": { + "activeTargets": { + "2": { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "resumeToken": "" + } + } + } + }, + { + "watchAck": [ + 2 + ] + }, + { + "watchCurrent": [ + [ + 2 + ], + "resume-token-1000" + ], + "watchSnapshot": 2000, + "expect": [ + { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "errorCode": 0, + "fromCache": false, + "hasPendingWrites": false + } + ] + }, + { + "watchEntity": { + "docs": [ + [ + "collection/1", + 2000, + { + "v": 2 + } + ] + ], + "targets": [ + 2 + ] + } + }, + { + "watchFilter": [ + [ + 2 + ], + "collection/1" + ], + "watchSnapshot": 2000, + "expect": [ + { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "added": [ + [ + "collection/1", + 2000, + { + "v": 2 + } + ] + ], + "errorCode": 0, + "fromCache": false, + "hasPendingWrites": false + } + ] + } + ] + }, + "Existence filter with empty target": { + "describeName": "Existence Filters:", + "itName": "Existence filter with empty target", + "tags": [], + "config": { + "useGarbageCollection": true + }, + "steps": [ + { + "userListen": [ + 2, + { + "path": "collection", + "filters": [], + "orderBys": [] + } + ], + "stateExpect": { + "activeTargets": { + "2": { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "resumeToken": "" + } + } + } + }, + { + "watchAck": [ + 2 + ] + }, + { + "watchCurrent": [ + [ + 2 + ], + "resume-token-1000" + ], + "watchSnapshot": 2000, + "expect": [ + { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "errorCode": 0, + "fromCache": false, + "hasPendingWrites": false + } + ] + }, + { + "watchFilter": [ + [ + 2 + ], + "collection/1" + ], + "watchSnapshot": 2000, + "expect": [ + { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "errorCode": 0, + "fromCache": true, + "hasPendingWrites": false + } + ] + } + ] + }, + "Existence filter ignored with pending target": { + "describeName": "Existence Filters:", + "itName": "Existence filter ignored with pending target", + "tags": [], + "config": { + "useGarbageCollection": false + }, + "steps": [ + { + "userListen": [ + 2, + { + "path": "collection", + "filters": [], + "orderBys": [] + } + ], + "stateExpect": { + "activeTargets": { + "2": { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "resumeToken": "" + } + } + } + }, + { + "watchAck": [ + 2 + ] + }, + { + "watchEntity": { + "docs": [ + [ + "collection/1", + 2000, + { + "v": 2 + } + ] + ], + "targets": [ + 2 + ] + } + }, + { + "watchCurrent": [ + [ + 2 + ], + "resume-token-1000" + ], + "watchSnapshot": 1000, + "expect": [ + { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "added": [ + [ + "collection/1", + 2000, + { + "v": 2 + } + ] + ], + "errorCode": 0, + "fromCache": false, + "hasPendingWrites": false + } + ] + }, + { + "userUnlisten": [ + 2, + { + "path": "collection", + "filters": [], + "orderBys": [] + } + ], + "stateExpect": { + "activeTargets": {} + } + }, + { + "userListen": [ + 2, + { + "path": "collection", + "filters": [], + "orderBys": [] + } + ], + "stateExpect": { + "activeTargets": { + "2": { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "resumeToken": "resume-token-1000" + } + } + }, + "expect": [ + { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "added": [ + [ + "collection/1", + 2000, + { + "v": 2 + } + ] + ], + "errorCode": 0, + "fromCache": true, + "hasPendingWrites": false + } + ] + }, + { + "watchFilter": [ + [ + 2 + ] + ] + }, + { + "watchRemove": { + "targetIds": [ + 2 + ] + } + }, + { + "watchAck": [ + 2 + ] + }, + { + "watchCurrent": [ + [ + 2 + ], + "resume-token-2000" + ], + "watchSnapshot": 2000, + "expect": [ + { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "errorCode": 0, + "fromCache": false, + "hasPendingWrites": false + } + ] + } + ] + }, "Existence filter mismatch triggers re-run of query": { "describeName": "Existence Filters:", - "itName": "Existence filter mismatch triggers re-run of query", + "itName": "Existence filter mismatch triggers re-run of query", + "tags": [], + "config": { + "useGarbageCollection": true + }, + "steps": [ + { + "userListen": [ + 2, + { + "path": "collection", + "filters": [], + "orderBys": [] + } + ], + "stateExpect": { + "activeTargets": { + "2": { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "resumeToken": "" + } + } + } + }, + { + "watchAck": [ + 2 + ] + }, + { + "watchEntity": { + "docs": [ + [ + "collection/1", + 1000, + { + "v": 1 + } + ], + [ + "collection/2", + 1000, + { + "v": 2 + } + ] + ], + "targets": [ + 2 + ] + } + }, + { + "watchCurrent": [ + [ + 2 + ], + "resume-token-1000" + ], + "watchSnapshot": 1000, + "expect": [ + { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "added": [ + [ + "collection/1", + 1000, + { + "v": 1 + } + ], + [ + "collection/2", + 1000, + { + "v": 2 + } + ] + ], + "errorCode": 0, + "fromCache": false, + "hasPendingWrites": false + } + ] + }, + { + "watchFilter": [ + [ + 2 + ], + "collection/1" + ], + "watchSnapshot": 2000, + "expect": [ + { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "errorCode": 0, + "fromCache": true, + "hasPendingWrites": false + } + ], + "stateExpect": { + "activeTargets": { + "2": { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "resumeToken": "" + } + } + } + }, + { + "watchRemove": { + "targetIds": [ + 2 + ] + } + }, + { + "watchAck": [ + 2 + ] + }, + { + "watchEntity": { + "docs": [ + [ + "collection/1", + 1000, + { + "v": 1 + } + ] + ], + "targets": [ + 2 + ] + } + }, + { + "watchCurrent": [ + [ + 2 + ], + "resume-token-2000" + ], + "watchSnapshot": 2000, + "stateExpect": { + "limboDocs": [ + "collection/2" + ], + "activeTargets": { + "1": { + "query": { + "path": "collection/2", + "filters": [], + "orderBys": [] + }, + "resumeToken": "" + }, + "2": { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "resumeToken": "" + } + } + } + }, + { + "watchAck": [ + 1 + ] + }, + { + "watchCurrent": [ + [ + 1 + ], + "resume-token-2000" + ], + "watchSnapshot": 2000, + "stateExpect": { + "limboDocs": [], + "activeTargets": { + "2": { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "resumeToken": "" + } + } + }, + "expect": [ + { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "removed": [ + [ + "collection/2", + 1000, + { + "v": 2 + } + ] + ], + "errorCode": 0, + "fromCache": false, + "hasPendingWrites": false + } + ] + } + ] + }, + "Existence filter mismatch will drop resume token": { + "describeName": "Existence Filters:", + "itName": "Existence filter mismatch will drop resume token", "tags": [], "config": { "useGarbageCollection": true @@ -62,7 +754,7 @@ [ 2 ], - "resume-token-1000" + "existence-filter-resume-token" ], "watchSnapshot": 1000, "expect": [ @@ -94,6 +786,32 @@ } ] }, + { + "watchStreamClose": { + "error": { + "code": 14, + "message": "Simulated Backend Error" + }, + "runBackoffTimer": true + }, + "stateExpect": { + "activeTargets": { + "2": { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "resumeToken": "existence-filter-resume-token" + } + } + } + }, + { + "watchAck": [ + 2 + ] + }, { "watchFilter": [ [ @@ -237,9 +955,9 @@ } ] }, - "Existence filter mismatch will drop resume token": { + "Existence filter handled at global snapshot": { "describeName": "Existence Filters:", - "itName": "Existence filter mismatch will drop resume token", + "itName": "Existence filter handled at global snapshot", "tags": [], "config": { "useGarbageCollection": true @@ -281,13 +999,6 @@ { "v": 1 } - ], - [ - "collection/2", - 1000, - { - "v": 2 - } ] ], "targets": [ @@ -300,7 +1011,7 @@ [ 2 ], - "existence-filter-resume-token" + "resume-token-1000" ], "watchSnapshot": 1000, "expect": [ @@ -317,13 +1028,6 @@ { "v": 1 } - ], - [ - "collection/2", - 1000, - { - "v": 2 - } ] ], "errorCode": 0, @@ -332,39 +1036,30 @@ } ] }, - { - "watchStreamClose": { - "error": { - "code": 14, - "message": "Simulated Backend Error" - }, - "runBackoffTimer": true - }, - "stateExpect": { - "activeTargets": { - "2": { - "query": { - "path": "collection", - "filters": [], - "orderBys": [] - }, - "resumeToken": "existence-filter-resume-token" - } - } - } - }, - { - "watchAck": [ - 2 - ] - }, { "watchFilter": [ [ 2 ], - "collection/1" - ], + "collection/1", + "collection/2" + ] + }, + { + "watchEntity": { + "docs": [ + [ + "collection/3", + 3000, + { + "v": 3 + } + ] + ], + "targets": [ + 2 + ] + }, "watchSnapshot": 2000, "expect": [ { @@ -373,6 +1068,15 @@ "filters": [], "orderBys": [] }, + "added": [ + [ + "collection/3", + 3000, + { + "v": 3 + } + ] + ], "errorCode": 0, "fromCache": true, "hasPendingWrites": false @@ -412,6 +1116,20 @@ { "v": 1 } + ], + [ + "collection/2", + 2000, + { + "v": 2 + } + ], + [ + "collection/3", + 3000, + { + "v": 3 + } ] ], "targets": [ @@ -424,25 +1142,55 @@ [ 2 ], - "resume-token-2000" + "resume-token-3000" + ], + "watchSnapshot": 3000, + "expect": [ + { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "added": [ + [ + "collection/2", + 2000, + { + "v": 2 + } + ] + ], + "errorCode": 0, + "fromCache": false, + "hasPendingWrites": false + } + ] + } + ] + }, + "Existence filter synthesizes deletes": { + "describeName": "Existence Filters:", + "itName": "Existence filter synthesizes deletes", + "tags": [], + "config": { + "useGarbageCollection": true + }, + "steps": [ + { + "userListen": [ + 2, + { + "path": "collection/a", + "filters": [], + "orderBys": [] + } ], - "watchSnapshot": 2000, "stateExpect": { - "limboDocs": [ - "collection/2" - ], "activeTargets": { - "1": { - "query": { - "path": "collection/2", - "filters": [], - "orderBys": [] - }, - "resumeToken": "" - }, "2": { "query": { - "path": "collection", + "path": "collection/a", "filters": [], "orderBys": [] }, @@ -453,43 +1201,75 @@ }, { "watchAck": [ - 1 + 2 ] }, + { + "watchEntity": { + "docs": [ + [ + "collection/a", + 1000, + { + "v": 1 + } + ] + ], + "targets": [ + 2 + ] + } + }, { "watchCurrent": [ [ - 1 + 2 ], - "resume-token-2000" + "resume-token-1000" ], - "watchSnapshot": 2000, - "stateExpect": { - "limboDocs": [], - "activeTargets": { - "2": { - "query": { - "path": "collection", - "filters": [], - "orderBys": [] - }, - "resumeToken": "" - } + "watchSnapshot": 1000, + "expect": [ + { + "query": { + "path": "collection/a", + "filters": [], + "orderBys": [] + }, + "added": [ + [ + "collection/a", + 1000, + { + "v": 1 + } + ] + ], + "errorCode": 0, + "fromCache": false, + "hasPendingWrites": false } - }, + ] + }, + { + "watchFilter": [ + [ + 2 + ] + ], + "watchSnapshot": 2000, "expect": [ { "query": { - "path": "collection", + "path": "collection/a", "filters": [], "orderBys": [] }, "removed": [ [ - "collection/2", + "collection/a", 1000, { - "v": 2 + "v": 1 } ] ], diff --git a/Firestore/Example/Tests/SpecTests/json/limbo_spec_test.json b/Firestore/Example/Tests/SpecTests/json/limbo_spec_test.json index ee2d883..a186496 100644 --- a/Firestore/Example/Tests/SpecTests/json/limbo_spec_test.json +++ b/Firestore/Example/Tests/SpecTests/json/limbo_spec_test.json @@ -1146,5 +1146,416 @@ ] } ] + }, + "Limbo documents handle receiving ack and then current": { + "describeName": "Limbo Documents:", + "itName": "Limbo documents handle receiving ack and then current", + "tags": [], + "config": { + "useGarbageCollection": false + }, + "steps": [ + { + "userListen": [ + 2, + { + "path": "collection", + "filters": [], + "orderBys": [] + } + ], + "stateExpect": { + "activeTargets": { + "2": { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "resumeToken": "" + } + } + } + }, + { + "watchAck": [ + 2 + ] + }, + { + "watchEntity": { + "docs": [ + [ + "collection/a", + 1000, + { + "include": true, + "key": "a" + } + ], + [ + "collection/b", + 1000, + { + "include": true, + "key": "b" + } + ] + ], + "targets": [ + 2 + ] + } + }, + { + "watchCurrent": [ + [ + 2 + ], + "resume-token-1000" + ], + "watchSnapshot": 1000, + "expect": [ + { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "added": [ + [ + "collection/a", + 1000, + { + "include": true, + "key": "a" + } + ], + [ + "collection/b", + 1000, + { + "include": true, + "key": "b" + } + ] + ], + "errorCode": 0, + "fromCache": false, + "hasPendingWrites": false + } + ] + }, + { + "userUnlisten": [ + 2, + { + "path": "collection", + "filters": [], + "orderBys": [] + } + ], + "stateExpect": { + "activeTargets": {} + } + }, + { + "userListen": [ + 4, + { + "path": "collection", + "limit": 1, + "filters": [ + [ + "include", + "==", + true + ] + ], + "orderBys": [] + } + ], + "stateExpect": { + "activeTargets": { + "4": { + "query": { + "path": "collection", + "limit": 1, + "filters": [ + [ + "include", + "==", + true + ] + ], + "orderBys": [] + }, + "resumeToken": "" + } + } + }, + "expect": [ + { + "query": { + "path": "collection", + "limit": 1, + "filters": [ + [ + "include", + "==", + true + ] + ], + "orderBys": [] + }, + "added": [ + [ + "collection/a", + 1000, + { + "include": true, + "key": "a" + } + ] + ], + "errorCode": 0, + "fromCache": true, + "hasPendingWrites": false + } + ] + }, + { + "watchAck": [ + 4 + ] + }, + { + "watchEntity": { + "docs": [ + [ + "collection/a", + 1000, + { + "include": true, + "key": "a" + } + ] + ], + "targets": [ + 4 + ] + } + }, + { + "watchCurrent": [ + [ + 4 + ], + "resume-token-2000" + ], + "watchSnapshot": 2000, + "expect": [ + { + "query": { + "path": "collection", + "limit": 1, + "filters": [ + [ + "include", + "==", + true + ] + ], + "orderBys": [] + }, + "errorCode": 0, + "fromCache": false, + "hasPendingWrites": false + } + ] + }, + { + "userPatch": [ + "collection/a", + { + "include": false + } + ], + "expect": [ + { + "query": { + "path": "collection", + "limit": 1, + "filters": [ + [ + "include", + "==", + true + ] + ], + "orderBys": [] + }, + "added": [ + [ + "collection/b", + 1000, + { + "include": true, + "key": "b" + } + ] + ], + "removed": [ + [ + "collection/a", + 1000, + { + "include": true, + "key": "a" + } + ] + ], + "errorCode": 0, + "fromCache": true, + "hasPendingWrites": false + } + ], + "stateExpect": { + "limboDocs": [ + "collection/b" + ], + "activeTargets": { + "1": { + "query": { + "path": "collection/b", + "filters": [], + "orderBys": [] + }, + "resumeToken": "" + }, + "4": { + "query": { + "path": "collection", + "limit": 1, + "filters": [ + [ + "include", + "==", + true + ] + ], + "orderBys": [] + }, + "resumeToken": "" + } + } + } + }, + { + "watchAck": [ + 1 + ] + }, + { + "watchEntity": { + "docs": [ + [ + "collection/b", + 1000, + { + "include": true, + "key": "b" + } + ] + ], + "targets": [ + 1 + ] + } + }, + { + "watchCurrent": [ + [ + 1 + ], + "resume-token-3000" + ], + "watchSnapshot": 3000 + }, + { + "watchEntity": { + "docs": [ + [ + "collection/a", + 1000, + { + "include": true, + "key": "a" + } + ] + ], + "removedTargets": [ + 4 + ] + } + }, + { + "watchEntity": { + "docs": [ + [ + "collection/b", + 1000, + { + "include": true, + "key": "b" + } + ] + ], + "targets": [ + 4 + ] + }, + "watchSnapshot": 4000, + "expect": [ + { + "query": { + "path": "collection", + "limit": 1, + "filters": [ + [ + "include", + "==", + true + ] + ], + "orderBys": [] + }, + "errorCode": 0, + "fromCache": false, + "hasPendingWrites": false + } + ], + "stateExpect": { + "limboDocs": [], + "activeTargets": { + "4": { + "query": { + "path": "collection", + "limit": 1, + "filters": [ + [ + "include", + "==", + true + ] + ], + "orderBys": [] + }, + "resumeToken": "" + } + } + } + } + ] } } diff --git a/Firestore/Example/Tests/SpecTests/json/limit_spec_test.json b/Firestore/Example/Tests/SpecTests/json/limit_spec_test.json index 5a02463..6aa1daa 100644 --- a/Firestore/Example/Tests/SpecTests/json/limit_spec_test.json +++ b/Firestore/Example/Tests/SpecTests/json/limit_spec_test.json @@ -287,7 +287,15 @@ "targets": [ 2 ] - }, + } + }, + { + "watchCurrent": [ + [ + 2 + ], + "resume-token-2000" + ], "watchSnapshot": 2000, "stateExpect": { "limboDocs": [ @@ -1144,7 +1152,15 @@ "targets": [ 2 ] - }, + } + }, + { + "watchCurrent": [ + [ + 2 + ], + "resume-token-2000" + ], "watchSnapshot": 2000, "stateExpect": { "limboDocs": [ diff --git a/Firestore/Example/Tests/SpecTests/json/listen_spec_test.json b/Firestore/Example/Tests/SpecTests/json/listen_spec_test.json index 7bfe557..e838d2f 100644 --- a/Firestore/Example/Tests/SpecTests/json/listen_spec_test.json +++ b/Firestore/Example/Tests/SpecTests/json/listen_spec_test.json @@ -238,6 +238,177 @@ } ] }, + "Doesn't raise events for empty target": { + "describeName": "Listens:", + "itName": "Doesn't raise events for empty target", + "tags": [], + "config": { + "useGarbageCollection": true + }, + "steps": [ + { + "userListen": [ + 2, + { + "path": "collection1", + "filters": [], + "orderBys": [] + } + ], + "stateExpect": { + "activeTargets": { + "2": { + "query": { + "path": "collection1", + "filters": [], + "orderBys": [] + }, + "resumeToken": "" + } + } + } + }, + { + "userListen": [ + 4, + { + "path": "collection2", + "filters": [], + "orderBys": [] + } + ], + "stateExpect": { + "activeTargets": { + "2": { + "query": { + "path": "collection1", + "filters": [], + "orderBys": [] + }, + "resumeToken": "" + }, + "4": { + "query": { + "path": "collection2", + "filters": [], + "orderBys": [] + }, + "resumeToken": "" + } + } + } + }, + { + "userListen": [ + 6, + { + "path": "collection3", + "filters": [], + "orderBys": [] + } + ], + "stateExpect": { + "activeTargets": { + "2": { + "query": { + "path": "collection1", + "filters": [], + "orderBys": [] + }, + "resumeToken": "" + }, + "4": { + "query": { + "path": "collection2", + "filters": [], + "orderBys": [] + }, + "resumeToken": "" + }, + "6": { + "query": { + "path": "collection3", + "filters": [], + "orderBys": [] + }, + "resumeToken": "" + } + } + } + }, + { + "watchAck": [ + 2 + ] + }, + { + "watchCurrent": [ + [ + 2 + ], + "resume-token-1000" + ] + }, + { + "watchAck": [ + 4 + ] + }, + { + "watchEntity": { + "docs": [ + [ + "collection2/a", + 1000, + { + "key": "a" + } + ] + ], + "targets": [ + 4 + ] + } + }, + { + "watchAck": [ + 6 + ], + "watchSnapshot": 1000, + "expect": [ + { + "query": { + "path": "collection1", + "filters": [], + "orderBys": [] + }, + "errorCode": 0, + "fromCache": false, + "hasPendingWrites": false + }, + { + "query": { + "path": "collection2", + "filters": [], + "orderBys": [] + }, + "added": [ + [ + "collection2/a", + 1000, + { + "key": "a" + } + ] + ], + "errorCode": 0, + "fromCache": true, + "hasPendingWrites": false + } + ] + } + ] + }, "Ensure correct query results with latency-compensated deletes": { "describeName": "Listens:", "itName": "Ensure correct query results with latency-compensated deletes", @@ -385,6 +556,79 @@ } ] }, + "Does not raise event for initial document delete": { + "describeName": "Listens:", + "itName": "Does not raise event for initial document delete", + "tags": [""], + "config": { + "useGarbageCollection": true + }, + "steps": [ + { + "userListen": [ + 2, + { + "path": "collection", + "filters": [], + "orderBys": [] + } + ], + "stateExpect": { + "activeTargets": { + "2": { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "resumeToken": "" + } + } + } + }, + { + "watchAck": [ + 2 + ] + }, + { + "watchEntity": { + "docs": [ + [ + "collection/a", + 1000, + null + ] + ], + "removedTargets": [ + 2 + ] + }, + "watchSnapshot": 1000 + }, + { + "watchCurrent": [ + [ + 2 + ], + "resume-token-2000" + ], + "watchSnapshot": 2000, + "expect": [ + { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "errorCode": 0, + "fromCache": false, + "hasPendingWrites": false + } + ] + } + ] + }, "Will process removals without waiting for a consistent snapshot": { "describeName": "Listens:", "itName": "Will process removals without waiting for a consistent snapshot", @@ -1690,5 +1934,836 @@ ] } ] + }, + "Synthesizes deletes for missing document": { + "describeName": "Listens:", + "itName": "Synthesizes deletes for missing document", + "tags": [], + "config": { + "useGarbageCollection": false + }, + "steps": [ + { + "userListen": [ + 2, + { + "path": "collection", + "filters": [], + "orderBys": [] + } + ], + "stateExpect": { + "activeTargets": { + "2": { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "resumeToken": "" + } + } + } + }, + { + "watchAck": [ + 2 + ] + }, + { + "watchEntity": { + "docs": [ + [ + "collection/a", + 1000, + { + "key": "a" + } + ], + [ + "collection/b", + 1000, + { + "key": "a" + } + ] + ], + "targets": [ + 2 + ] + } + }, + { + "watchCurrent": [ + [ + 2 + ], + "resume-token-1000" + ], + "watchSnapshot": 1000, + "expect": [ + { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "added": [ + [ + "collection/a", + 1000, + { + "key": "a" + } + ], + [ + "collection/b", + 1000, + { + "key": "a" + } + ] + ], + "errorCode": 0, + "fromCache": false, + "hasPendingWrites": false + } + ] + }, + { + "userUnlisten": [ + 2, + { + "path": "collection", + "filters": [], + "orderBys": [] + } + ], + "stateExpect": { + "activeTargets": {} + } + }, + { + "watchRemove": { + "targetIds": [ + 2 + ] + } + }, + { + "userListen": [ + 2, + { + "path": "collection", + "filters": [], + "orderBys": [] + } + ], + "stateExpect": { + "activeTargets": { + "2": { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "resumeToken": "resume-token-1000" + } + } + }, + "expect": [ + { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "added": [ + [ + "collection/a", + 1000, + { + "key": "a" + } + ], + [ + "collection/b", + 1000, + { + "key": "a" + } + ] + ], + "errorCode": 0, + "fromCache": true, + "hasPendingWrites": false + } + ] + }, + { + "userUnlisten": [ + 2, + { + "path": "collection", + "filters": [], + "orderBys": [] + } + ], + "stateExpect": { + "activeTargets": {} + } + }, + { + "userListen": [ + 4, + { + "path": "collection/a", + "filters": [], + "orderBys": [] + } + ], + "stateExpect": { + "activeTargets": { + "4": { + "query": { + "path": "collection/a", + "filters": [], + "orderBys": [] + }, + "resumeToken": "" + } + } + }, + "expect": [ + { + "query": { + "path": "collection/a", + "filters": [], + "orderBys": [] + }, + "added": [ + [ + "collection/a", + 1000, + { + "key": "a" + } + ] + ], + "errorCode": 0, + "fromCache": true, + "hasPendingWrites": false + } + ] + }, + { + "watchAck": [ + 4 + ] + }, + { + "watchCurrent": [ + [ + 4 + ], + "resume-token-2000" + ], + "watchSnapshot": 2000, + "expect": [ + { + "query": { + "path": "collection/a", + "filters": [], + "orderBys": [] + }, + "removed": [ + [ + "collection/a", + 1000, + { + "key": "a" + } + ] + ], + "errorCode": 0, + "fromCache": false, + "hasPendingWrites": false + } + ] + }, + { + "userUnlisten": [ + 4, + { + "path": "collection/a", + "filters": [], + "orderBys": [] + } + ], + "stateExpect": { + "activeTargets": {} + } + }, + { + "watchRemove": { + "targetIds": [ + 4 + ] + } + }, + { + "userListen": [ + 2, + { + "path": "collection", + "filters": [], + "orderBys": [] + } + ], + "stateExpect": { + "activeTargets": { + "2": { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "resumeToken": "resume-token-1000" + } + } + }, + "expect": [ + { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "added": [ + [ + "collection/b", + 1000, + { + "key": "a" + } + ] + ], + "errorCode": 0, + "fromCache": true, + "hasPendingWrites": false + } + ] + } + ] + }, + "Re-opens target without existence filter": { + "describeName": "Listens:", + "itName": "Re-opens target without existence filter", + "tags": [], + "config": { + "useGarbageCollection": false + }, + "steps": [ + { + "userListen": [ + 2, + { + "path": "collection", + "filters": [], + "orderBys": [] + } + ], + "stateExpect": { + "activeTargets": { + "2": { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "resumeToken": "" + } + } + } + }, + { + "watchAck": [ + 2 + ] + }, + { + "watchEntity": { + "docs": [ + [ + "collection/a", + 1000, + { + "key": "a" + } + ] + ], + "targets": [ + 2 + ] + } + }, + { + "watchCurrent": [ + [ + 2 + ], + "resume-token-1000" + ], + "watchSnapshot": 1000, + "expect": [ + { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "added": [ + [ + "collection/a", + 1000, + { + "key": "a" + } + ] + ], + "errorCode": 0, + "fromCache": false, + "hasPendingWrites": false + } + ] + }, + { + "userUnlisten": [ + 2, + { + "path": "collection", + "filters": [], + "orderBys": [] + } + ], + "stateExpect": { + "activeTargets": {} + } + }, + { + "watchRemove": { + "targetIds": [ + 2 + ] + } + }, + { + "userListen": [ + 2, + { + "path": "collection", + "filters": [], + "orderBys": [] + } + ], + "stateExpect": { + "activeTargets": { + "2": { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "resumeToken": "resume-token-1000" + } + } + }, + "expect": [ + { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "added": [ + [ + "collection/a", + 1000, + { + "key": "a" + } + ] + ], + "errorCode": 0, + "fromCache": true, + "hasPendingWrites": false + } + ] + }, + { + "watchAck": [ + 2 + ] + }, + { + "watchEntity": { + "docs": [ + [ + "collection/a", + 2000, + null + ] + ], + "removedTargets": [ + 2 + ] + } + }, + { + "watchCurrent": [ + [ + 2 + ], + "resume-token-2000" + ], + "watchSnapshot": 2000, + "expect": [ + { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "removed": [ + [ + "collection/a", + 1000, + { + "key": "a" + } + ] + ], + "errorCode": 0, + "fromCache": false, + "hasPendingWrites": false + } + ] + } + ] + }, + "Ignores update from inactive target": { + "describeName": "Listens:", + "itName": "Ignores update from inactive target", + "tags": [], + "config": { + "useGarbageCollection": false + }, + "steps": [ + { + "userListen": [ + 2, + { + "path": "collection", + "filters": [], + "orderBys": [] + } + ], + "stateExpect": { + "activeTargets": { + "2": { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "resumeToken": "" + } + } + } + }, + { + "watchAck": [ + 2 + ] + }, + { + "watchEntity": { + "docs": [ + [ + "collection/a", + 1000, + { + "key": "a" + } + ] + ], + "targets": [ + 2 + ] + } + }, + { + "watchCurrent": [ + [ + 2 + ], + "resume-token-1000" + ], + "watchSnapshot": 1000, + "expect": [ + { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "added": [ + [ + "collection/a", + 1000, + { + "key": "a" + } + ] + ], + "errorCode": 0, + "fromCache": false, + "hasPendingWrites": false + } + ] + }, + { + "userUnlisten": [ + 2, + { + "path": "collection", + "filters": [], + "orderBys": [] + } + ], + "stateExpect": { + "activeTargets": {} + } + }, + { + "watchEntity": { + "docs": [ + [ + "collection/b", + 2000, + { + "key": "b" + } + ] + ], + "targets": [ + 2 + ] + }, + "watchSnapshot": 2000 + }, + { + "watchRemove": { + "targetIds": [ + 2 + ] + } + }, + { + "userListen": [ + 2, + { + "path": "collection", + "filters": [], + "orderBys": [] + } + ], + "stateExpect": { + "activeTargets": { + "2": { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "resumeToken": "resume-token-1000" + } + } + }, + "expect": [ + { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "added": [ + [ + "collection/a", + 1000, + { + "key": "a" + } + ] + ], + "errorCode": 0, + "fromCache": true, + "hasPendingWrites": false + } + ] + } + ] + }, + "Does not synthesize deletes for previously acked documents": { + "describeName": "Listens:", + "itName": "Does not synthesize deletes for previously acked documents", + "tags": [], + "config": { + "useGarbageCollection": false + }, + "steps": [ + { + "userListen": [ + 2, + { + "path": "collection/a", + "filters": [], + "orderBys": [] + } + ], + "stateExpect": { + "activeTargets": { + "2": { + "query": { + "path": "collection/a", + "filters": [], + "orderBys": [] + }, + "resumeToken": "" + } + } + } + }, + { + "watchAck": [ + 2 + ] + }, + { + "watchEntity": { + "docs": [ + [ + "collection/a", + 1000, + { + "key": "a" + } + ] + ], + "targets": [ + 2 + ] + }, + "watchSnapshot": 1000, + "expect": [ + { + "query": { + "path": "collection/a", + "filters": [], + "orderBys": [] + }, + "added": [ + [ + "collection/a", + 1000, + { + "key": "a" + } + ] + ], + "errorCode": 0, + "fromCache": true, + "hasPendingWrites": false + } + ] + }, + { + "watchCurrent": [ + [ + 2 + ], + "resume-token-2000" + ], + "watchSnapshot": 2000, + "expect": [ + { + "query": { + "path": "collection/a", + "filters": [], + "orderBys": [] + }, + "errorCode": 0, + "fromCache": false, + "hasPendingWrites": false + } + ] + }, + { + "userUnlisten": [ + 2, + { + "path": "collection/a", + "filters": [], + "orderBys": [] + } + ], + "stateExpect": { + "activeTargets": {} + } + }, + { + "userListen": [ + 2, + { + "path": "collection/a", + "filters": [], + "orderBys": [] + } + ], + "stateExpect": { + "activeTargets": { + "2": { + "query": { + "path": "collection/a", + "filters": [], + "orderBys": [] + }, + "resumeToken": "resume-token-2000" + } + } + }, + "expect": [ + { + "query": { + "path": "collection/a", + "filters": [], + "orderBys": [] + }, + "added": [ + [ + "collection/a", + 1000, + { + "key": "a" + } + ] + ], + "errorCode": 0, + "fromCache": true, + "hasPendingWrites": false + } + ] + } + ] } } diff --git a/Firestore/Example/Tests/Util/FSTHelpers.h b/Firestore/Example/Tests/Util/FSTHelpers.h index ccc01ca..7946c06 100644 --- a/Firestore/Example/Tests/Util/FSTHelpers.h +++ b/Firestore/Example/Tests/Util/FSTHelpers.h @@ -16,11 +16,11 @@ #import -#include #include #import "Firestore/Source/Core/FSTTypes.h" #import "Firestore/Source/Model/FSTDocumentDictionary.h" +#import "Firestore/Source/Remote/FSTRemoteEvent.h" #include "Firestore/core/src/firebase/firestore/model/field_path.h" #include "Firestore/core/src/firebase/firestore/model/field_value.h" @@ -148,6 +148,42 @@ inline NSString *FSTRemoveExceptionPrefix(NSString *exception) { XCTAssertTrue(didThrow, ##__VA_ARGS__); \ } while (0) +/** + * An implementation of FSTTargetMetadataProvider that provides controlled access to the + * `FSTTargetMetadataProvider` callbacks. Any target accessed via these callbacks must be + * registered beforehand via the factory methods or via `setSyncedKeys:forQueryData:`. + */ +@interface FSTTestTargetMetadataProvider : NSObject + +/** + * Creates an FSTTestTargetMetadataProvider that behaves as if there's an established listen for + * each of the given targets, where each target has previously seen query results containing just + * the given documentKey. + * + * Internally this means that the `remoteKeysForTarget` callback for these targets will return just + * the documentKey and that the provided targets will be returned as active from the + * `queryDataForTarget` target. + */ ++ (instancetype)providerWithSingleResultForKey:(firebase::firestore::model::DocumentKey)documentKey + targets:(NSArray *)targets; + +/** + * Creates an FSTTestTargetMetadataProvider that behaves as if there's an established listen for + * each of the given targets, where each target has not seen any previous document. + * + * Internally this means that the `remoteKeysForTarget` callback for these targets will return an + * empty set of document keys and that the provided targets will be returned as active from the + * `queryDataForTarget` target. + */ ++ (instancetype)providerWithEmptyResultForKey:(firebase::firestore::model::DocumentKey)documentKey + targets:(NSArray *)targets; + +/** Sets or replaces the local state for the provided query data. */ +- (void)setSyncedKeys:(firebase::firestore::model::DocumentKeySet)keys + forQueryData:(FSTQueryData *)queryData; + +@end + /** Creates a new FIRTimestamp from components. Note that year, month, and day are all one-based. */ FIRTimestamp *FSTTestTimestamp(int year, int month, int day, int hour, int minute, int second); @@ -250,6 +286,9 @@ FSTDeleteMutation *FSTTestDeleteMutation(NSString *path); /** Converts a list of documents to a sorted map. */ FSTMaybeDocumentDictionary *FSTTestDocUpdates(NSArray *docs); +/** Creates a remote event that inserts a new document. */ +FSTRemoteEvent *FSTTestAddedRemoteEvent(FSTMaybeDocument *doc, NSArray *addedToTargets); + /** Creates a remote event with changes to a document. */ FSTRemoteEvent *FSTTestUpdateRemoteEvent(FSTMaybeDocument *doc, NSArray *updatedInTargets, @@ -260,6 +299,19 @@ FSTLocalViewChanges *FSTTestViewChanges(FSTQuery *query, NSArray *addedKeys, NSArray *removedKeys); +/** Creates a test target change that acks all 'docs' and marks the target as CURRENT */ +FSTTargetChange *FSTTestTargetChangeAckDocuments(firebase::firestore::model::DocumentKeySet docs); + +/** Creates a test target change that marks the target as CURRENT */ +FSTTargetChange *FSTTestTargetChangeMarkCurrent(); + +/** Creates a test target change. */ +FSTTargetChange *FSTTestTargetChange(firebase::firestore::model::DocumentKeySet added, + firebase::firestore::model::DocumentKeySet modified, + firebase::firestore::model::DocumentKeySet removed, + NSData *resumeToken, + BOOL current); + /** Creates a resume token to match the given snapshot version. */ NSData *_Nullable FSTTestResumeTokenFromSnapshotVersion(FSTTestSnapshotVersion watchSnapshot); diff --git a/Firestore/Example/Tests/Util/FSTHelpers.mm b/Firestore/Example/Tests/Util/FSTHelpers.mm index 5ed4fd5..8ece82f 100644 --- a/Firestore/Example/Tests/Util/FSTHelpers.mm +++ b/Firestore/Example/Tests/Util/FSTHelpers.mm @@ -22,7 +22,7 @@ #include #include -#include +#include #include #include @@ -58,6 +58,7 @@ namespace util = firebase::firestore::util; namespace testutil = firebase::firestore::testutil; using firebase::firestore::model::DatabaseId; using firebase::firestore::model::DocumentKey; +using firebase::firestore::model::DocumentKeySet; using firebase::firestore::model::FieldMask; using firebase::firestore::model::FieldPath; using firebase::firestore::model::FieldTransform; @@ -65,8 +66,8 @@ using firebase::firestore::model::FieldValue; using firebase::firestore::model::Precondition; using firebase::firestore::model::ResourcePath; using firebase::firestore::model::ServerTimestampTransform; +using firebase::firestore::model::SnapshotVersion; using firebase::firestore::model::TransformOperation; -using firebase::firestore::model::DocumentKeySet; NS_ASSUME_NONNULL_BEGIN @@ -298,29 +299,125 @@ FSTViewSnapshot *_Nullable FSTTestApplyChanges(FSTView *view, .snapshot; } +@implementation FSTTestTargetMetadataProvider { + std::unordered_map _syncedKeys; + std::unordered_map _queryData; +} + ++ (instancetype)providerWithSingleResultForKey:(DocumentKey)documentKey + targets:(NSArray *)targets { + FSTTestTargetMetadataProvider *metadataProvider = [FSTTestTargetMetadataProvider new]; + FSTQuery *query = [FSTQuery queryWithPath:documentKey.path()]; + + for (FSTBoxedTargetID *targetID in targets) { + FSTQueryData *queryData = [[FSTQueryData alloc] initWithQuery:query + targetID:targetID.intValue + listenSequenceNumber:0 + purpose:FSTQueryPurposeListen]; + [metadataProvider setSyncedKeys:DocumentKeySet{documentKey} forQueryData:queryData]; + } + + return metadataProvider; +} + ++ (instancetype)providerWithEmptyResultForKey:(DocumentKey)documentKey + targets:(NSArray *)targets { + FSTTestTargetMetadataProvider *metadataProvider = [FSTTestTargetMetadataProvider new]; + FSTQuery *query = [FSTQuery queryWithPath:documentKey.path()]; + + for (FSTBoxedTargetID *targetID in targets) { + FSTQueryData *queryData = [[FSTQueryData alloc] initWithQuery:query + targetID:targetID.intValue + listenSequenceNumber:0 + purpose:FSTQueryPurposeListen]; + [metadataProvider setSyncedKeys:DocumentKeySet {} forQueryData:queryData]; + } + + return metadataProvider; +} + +- (void)setSyncedKeys:(DocumentKeySet)keys forQueryData:(FSTQueryData *)queryData { + _syncedKeys[queryData.targetID] = keys; + _queryData[queryData.targetID] = queryData; +} + +- (DocumentKeySet)remoteKeysForTarget:(FSTBoxedTargetID *)targetID { + auto it = _syncedKeys.find(targetID.intValue); + HARD_ASSERT(it != _syncedKeys.end(), "Cannot process unknown target %s", targetID.intValue); + return it->second; +} + +- (nullable FSTQueryData *)queryDataForTarget:(FSTBoxedTargetID *)targetID { + auto it = _queryData.find(targetID.intValue); + HARD_ASSERT(it != _queryData.end(), "Cannot process unknown target %s", targetID.intValue); + return it->second; +} + +@end + +FSTRemoteEvent *FSTTestAddedRemoteEvent(FSTMaybeDocument *doc, + NSArray *addedToTargets) { + HARD_ASSERT(![doc isKindOfClass:[FSTDocument class]] || ![(FSTDocument *)doc hasLocalMutations], + "Docs from remote updates shouldn't have local changes."); + FSTDocumentWatchChange *change = + [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:addedToTargets + removedTargetIDs:{} + documentKey:doc.key + document:doc]; + FSTWatchChangeAggregator *aggregator = [[FSTWatchChangeAggregator alloc] + initWithTargetMetadataProvider:[FSTTestTargetMetadataProvider + providerWithEmptyResultForKey:doc.key + targets:addedToTargets]]; + [aggregator handleDocumentChange:change]; + return [aggregator remoteEventAtSnapshotVersion:doc.version]; +} + FSTRemoteEvent *FSTTestUpdateRemoteEvent(FSTMaybeDocument *doc, - NSArray *updatedInTargets, - NSArray *removedFromTargets) { + NSArray *updatedInTargets, + NSArray *removedFromTargets) { + HARD_ASSERT(![doc isKindOfClass:[FSTDocument class]] || ![(FSTDocument *)doc hasLocalMutations], + "Docs from remote updates shouldn't have local changes."); FSTDocumentWatchChange *change = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:updatedInTargets removedTargetIDs:removedFromTargets documentKey:doc.key document:doc]; - NSMutableDictionary *listens = [NSMutableDictionary dictionary]; - FSTQueryData *dummyQueryData = [FSTQueryData alloc]; - for (NSNumber *targetID in updatedInTargets) { - listens[targetID] = dummyQueryData; - } - for (NSNumber *targetID in removedFromTargets) { - listens[targetID] = dummyQueryData; - } - NSMutableDictionary *pending = [NSMutableDictionary dictionary]; - FSTWatchChangeAggregator *aggregator = - [[FSTWatchChangeAggregator alloc] initWithSnapshotVersion:doc.version - listenTargets:listens - pendingTargetResponses:pending]; - [aggregator addWatchChange:change]; - return [aggregator remoteEvent]; + NSArray *targets = + [updatedInTargets arrayByAddingObjectsFromArray:removedFromTargets]; + FSTWatchChangeAggregator *aggregator = [[FSTWatchChangeAggregator alloc] + initWithTargetMetadataProvider:[FSTTestTargetMetadataProvider + providerWithSingleResultForKey:doc.key + targets:targets]]; + [aggregator handleDocumentChange:change]; + return [aggregator remoteEventAtSnapshotVersion:doc.version]; +} + +FSTTargetChange *FSTTestTargetChangeMarkCurrent() { + return [[FSTTargetChange alloc] initWithResumeToken:[NSData data] + current:YES + addedDocuments:DocumentKeySet {} + modifiedDocuments:DocumentKeySet {} + removedDocuments:DocumentKeySet{}]; +} + +FSTTargetChange *FSTTestTargetChangeAckDocuments(DocumentKeySet docs) { + return [[FSTTargetChange alloc] initWithResumeToken:[NSData data] + current:YES + addedDocuments:docs + modifiedDocuments:DocumentKeySet {} + removedDocuments:DocumentKeySet{}]; +} + +FSTTargetChange *FSTTestTargetChange(DocumentKeySet added, + DocumentKeySet modified, + DocumentKeySet removed, + NSData *resumeToken, + BOOL current) { + return [[FSTTargetChange alloc] initWithResumeToken:resumeToken + current:current + addedDocuments:added + modifiedDocuments:modified + removedDocuments:removed]; } /** Creates a resume token to match the given snapshot version. */ diff --git a/Firestore/Source/Core/FSTSyncEngine.mm b/Firestore/Source/Core/FSTSyncEngine.mm index 7d0c1a3..89cb774 100644 --- a/Firestore/Source/Core/FSTSyncEngine.mm +++ b/Firestore/Source/Core/FSTSyncEngine.mm @@ -295,21 +295,6 @@ static const FSTListenSequenceNumber kIrrelevantSequenceNumber = -1; - (void)applyRemoteEvent:(FSTRemoteEvent *)remoteEvent { [self assertDelegateExistsForSelector:_cmd]; - // Make sure limbo documents are deleted if there were no results. - // Filter out document additions to targets that they already belong to. - [remoteEvent.targetChanges enumerateKeysAndObjectsUsingBlock:^( - FSTBoxedTargetID *_Nonnull targetID, - FSTTargetChange *_Nonnull targetChange, BOOL *_Nonnull stop) { - const auto iter = self->_limboKeysByTarget.find([targetID intValue]); - if (iter == self->_limboKeysByTarget.end()) { - FSTQueryView *qv = self.queryViewsByTarget[targetID]; - HARD_ASSERT(qv, "Missing queryview for non-limbo query: %s", [targetID intValue]); - [targetChange.mapping filterUpdatesUsingExistingKeys:qv.view.syncedDocuments]; - } else { - [remoteEvent synthesizeDeleteForLimboTargetChange:targetChange key:iter->second]; - } - }]; - FSTMaybeDocumentDictionary *changes = [self.localStore applyRemoteEvent:remoteEvent]; [self emitNewSnapshotsWithChanges:changes remoteEvent:remoteEvent]; } @@ -345,18 +330,16 @@ static const FSTListenSequenceNumber kIrrelevantSequenceNumber = -1; // It's a limbo doc. Create a synthetic event saying it was deleted. This is kind of a hack. // Ideally, we would have a method in the local store to purge a document. However, it would // be tricky to keep all of the local store's invariants with another method. - NSMutableDictionary *targetChanges = - [NSMutableDictionary dictionary]; FSTDeletedDocument *doc = [FSTDeletedDocument documentWithKey:limboKey version:SnapshotVersion::None()]; DocumentKeySet limboDocuments = DocumentKeySet{doc.key}; - FSTRemoteEvent *event = - [[FSTRemoteEvent alloc] initWithSnapshotVersion:SnapshotVersion::None() - targetChanges:targetChanges - documentUpdates:{ - { limboKey, doc } - } - limboDocuments:std::move(limboDocuments)]; + FSTRemoteEvent *event = [[FSTRemoteEvent alloc] initWithSnapshotVersion:SnapshotVersion::None() + targetChanges:{} + targetMismatches:{} + documentUpdates:{ + { limboKey, doc } + } + limboDocuments:std::move(limboDocuments)]; [self applyRemoteEvent:event]; } else { FSTQueryView *queryView = self.queryViewsByTarget[@(targetID)]; @@ -439,7 +422,14 @@ static const FSTListenSequenceNumber kIrrelevantSequenceNumber = -1; FSTDocumentDictionary *docs = [self.localStore executeQuery:queryView.query]; viewDocChanges = [view computeChangesWithDocuments:docs previousChanges:viewDocChanges]; } - FSTTargetChange *_Nullable targetChange = remoteEvent.targetChanges[@(queryView.targetID)]; + + FSTTargetChange *_Nullable targetChange = nil; + if (remoteEvent) { + auto it = remoteEvent.targetChanges.find(queryView.targetID); + if (it != remoteEvent.targetChanges.end()) { + targetChange = it->second; + } + } FSTViewChange *viewChange = [queryView.view applyChangesToDocuments:viewDocChanges targetChange:targetChange]; @@ -531,6 +521,11 @@ static const FSTListenSequenceNumber kIrrelevantSequenceNumber = -1; [self.remoteStore userDidChange:user]; } +- (firebase::firestore::model::DocumentKeySet)remoteKeysForTarget:(FSTBoxedTargetID *)targetId { + FSTQueryView *queryView = self.queryViewsByTarget[targetId]; + return queryView ? queryView.view.syncedDocuments : DocumentKeySet{}; +} + @end NS_ASSUME_NONNULL_END diff --git a/Firestore/Source/Core/FSTView.mm b/Firestore/Source/Core/FSTView.mm index 63efd4e..f954731 100644 --- a/Firestore/Source/Core/FSTView.mm +++ b/Firestore/Source/Core/FSTView.mm @@ -401,28 +401,18 @@ static NSComparisonResult FSTCompareDocumentViewChangeTypes(FSTDocumentViewChang */ - (void)applyTargetChange:(nullable FSTTargetChange *)targetChange { if (targetChange) { - FSTTargetMapping *targetMapping = targetChange.mapping; - if ([targetMapping isKindOfClass:[FSTResetMapping class]]) { - _syncedDocuments = ((FSTResetMapping *)targetMapping).documents; - } else if ([targetMapping isKindOfClass:[FSTUpdateMapping class]]) { - for (const DocumentKey &key : ((FSTUpdateMapping *)targetMapping).addedDocuments) { - _syncedDocuments = _syncedDocuments.insert(key); - } - for (const DocumentKey &key : ((FSTUpdateMapping *)targetMapping).removedDocuments) { - _syncedDocuments = _syncedDocuments.erase(key); - } + for (const DocumentKey &key : targetChange.addedDocuments) { + _syncedDocuments = _syncedDocuments.insert(key); } - - switch (targetChange.currentStatusUpdate) { - case FSTCurrentStatusUpdateMarkCurrent: - self.current = YES; - break; - case FSTCurrentStatusUpdateMarkNotCurrent: - self.current = NO; - break; - case FSTCurrentStatusUpdateNone: - break; + for (const DocumentKey &key : targetChange.modifiedDocuments) { + HARD_ASSERT(_syncedDocuments.find(key) != _syncedDocuments.end(), + "Modified document %s not found in view.", key.ToString()); } + for (const DocumentKey &key : targetChange.removedDocuments) { + _syncedDocuments = _syncedDocuments.erase(key); + } + + self.current = targetChange.current; } } diff --git a/Firestore/Source/Local/FSTLocalStore.mm b/Firestore/Source/Local/FSTLocalStore.mm index 7469c71..6aab78e 100644 --- a/Firestore/Source/Local/FSTLocalStore.mm +++ b/Firestore/Source/Local/FSTLocalStore.mm @@ -268,46 +268,32 @@ NS_ASSUME_NONNULL_BEGIN FSTListenSequenceNumber sequenceNumber = [self.listenSequence next]; id queryCache = self.queryCache; - [remoteEvent.targetChanges enumerateKeysAndObjectsUsingBlock:^( - NSNumber *targetIDNumber, FSTTargetChange *change, BOOL *stop) { - FSTTargetID targetID = targetIDNumber.intValue; + for (const auto &entry : remoteEvent.targetChanges) { + FSTTargetID targetID = entry.first; + FSTBoxedTargetID *boxedTargetID = @(targetID); + FSTTargetChange *change = entry.second; // Do not ref/unref unassigned targetIDs - it may lead to leaks. - FSTQueryData *queryData = self.targetIDs[targetIDNumber]; + FSTQueryData *queryData = self.targetIDs[boxedTargetID]; if (!queryData) { - return; + continue; } + [queryCache removeMatchingKeys:change.removedDocuments forTargetID:targetID]; + [queryCache addMatchingKeys:change.addedDocuments forTargetID:targetID]; + // Update the resume token if the change includes one. Don't clear any preexisting value. // Bump the sequence number as well, so that documents being removed now are ordered later // than documents that were previously removed from this target. NSData *resumeToken = change.resumeToken; if (resumeToken.length > 0) { - queryData = [queryData queryDataByReplacingSnapshotVersion:change.snapshotVersion + queryData = [queryData queryDataByReplacingSnapshotVersion:remoteEvent.snapshotVersion resumeToken:resumeToken sequenceNumber:sequenceNumber]; - self.targetIDs[targetIDNumber] = queryData; + self.targetIDs[boxedTargetID] = queryData; [self.queryCache updateQueryData:queryData]; } - - FSTTargetMapping *mapping = change.mapping; - if (mapping) { - // First make sure that all references are deleted. - if ([mapping isKindOfClass:[FSTResetMapping class]]) { - FSTResetMapping *reset = (FSTResetMapping *)mapping; - [queryCache removeMatchingKeysForTargetID:targetID]; - [queryCache addMatchingKeys:reset.documents forTargetID:targetID]; - - } else if ([mapping isKindOfClass:[FSTUpdateMapping class]]) { - FSTUpdateMapping *update = (FSTUpdateMapping *)mapping; - [queryCache removeMatchingKeys:update.removedDocuments forTargetID:targetID]; - [queryCache addMatchingKeys:update.addedDocuments forTargetID:targetID]; - - } else { - HARD_FAIL("Unknown mapping type: %s", mapping); - } - } - }]; + } // TODO(klimt): This could probably be an NSMutableDictionary. DocumentKeySet changedDocKeys; diff --git a/Firestore/Source/Remote/FSTRemoteEvent.h b/Firestore/Source/Remote/FSTRemoteEvent.h index c84e34d..9ea0f9c 100644 --- a/Firestore/Source/Remote/FSTRemoteEvent.h +++ b/Firestore/Source/Remote/FSTRemoteEvent.h @@ -17,6 +17,9 @@ #import #include +#include +#include +#include #import "Firestore/Source/Core/FSTTypes.h" #import "Firestore/Source/Model/FSTDocumentDictionary.h" @@ -30,128 +33,82 @@ @class FSTMaybeDocument; @class FSTWatchChange; @class FSTQueryData; +@class FSTDocumentWatchChange; +@class FSTWatchTargetChange; +@class FSTExistenceFilterWatchChange; NS_ASSUME_NONNULL_BEGIN -#pragma mark - FSTTargetMapping - -/** - * TargetMapping represents a change to the documents in a query from the server. This can either - * be an incremental Update or a full Reset. - * - *

This is an empty abstract class so that all the different kinds of changes can have a common - * base class. - */ -@interface FSTTargetMapping : NSObject - -/** - * Strips out mapping changes that aren't actually changes. That is, if the document already - * existed in the target, and is being added in the target, and this is not a reset, we can - * skip doing the work to associate the document with the target because it has already been done. - */ -- (void)filterUpdatesUsingExistingKeys: - (const firebase::firestore::model::DocumentKeySet &)existingKeys; - -@end - -#pragma mark - FSTResetMapping - -/** The new set of documents to replace the current documents for a target. */ -@interface FSTResetMapping : FSTTargetMapping - /** - * Creates a new mapping with the keys for the given documents added. This is intended primarily - * for testing. + * Interface implemented by RemoteStore to expose target metadata to the FSTWatchChangeAggregator. */ -+ (FSTResetMapping *)mappingWithDocuments:(NSArray *)documents; - -/** The new set of documents for the target. */ -- (const firebase::firestore::model::DocumentKeySet &)documents; -@end - -#pragma mark - FSTUpdateMapping +@protocol FSTTargetMetadataProvider /** - * A target should update its set of documents with the given added/removed set of documents. + * Returns the set of remote document keys for the given target ID as of the last raised snapshot. */ -@interface FSTUpdateMapping : FSTTargetMapping +- (firebase::firestore::model::DocumentKeySet)remoteKeysForTarget:(FSTBoxedTargetID *)targetID; /** - * Creates a new mapping with the keys for the given documents added. This is intended primarily - * for testing. + * Returns the FSTQueryData for an active target ID or 'null' if this query has become inactive */ -+ (FSTUpdateMapping *)mappingWithAddedDocuments:(NSArray *)added - removedDocuments:(NSArray *)removed; +- (nullable FSTQueryData *)queryDataForTarget:(FSTBoxedTargetID *)targetID; -- (firebase::firestore::model::DocumentKeySet)applyTo: - (const firebase::firestore::model::DocumentKeySet &)keys; - -/** The documents added to the target. */ -- (const firebase::firestore::model::DocumentKeySet &)addedDocuments; -/** The documents removed from the target. */ -- (const firebase::firestore::model::DocumentKeySet &)removedDocuments; @end #pragma mark - FSTTargetChange /** - * Represents an update to the current status of a target, either explicitly having no new state, or - * the new value to set. Note "current" has special meaning in the RPC protocol that implies that a - * target is both up-to-date and consistent with the rest of the watch stream. - */ -typedef NS_ENUM(NSUInteger, FSTCurrentStatusUpdate) { - /** The current status is not affected and should not be modified */ - FSTCurrentStatusUpdateNone, - /** The target must be marked as no longer "current" */ - FSTCurrentStatusUpdateMarkNotCurrent, - /** The target must be marked as "current" */ - FSTCurrentStatusUpdateMarkCurrent, -}; - -/** - * A part of an FSTRemoteEvent specifying set of changes to a specific target. These changes track - * what documents are currently included in the target as well as the current snapshot version and - * resume token but the actual changes *to* documents are not part of the FSTTargetChange since - * documents may be part of multiple targets. + * An FSTTargetChange specifies the set of changes for a specific target as part of an + * FSTRemoteEvent. These changes track which documents are added, modified or emoved, as well as the + * target's resume token and whether the target is marked CURRENT. + * + * The actual changes *to* documents are not part of the FSTTargetChange since documents may be part + * of multiple targets. */ @interface FSTTargetChange : NSObject /** * Creates a new target change with the given SnapshotVersion. */ -- (instancetype)initWithSnapshotVersion: - (firebase::firestore::model::SnapshotVersion)snapshotVersion; +- (instancetype)initWithResumeToken:(NSData *)resumeToken + current:(BOOL)current + addedDocuments:(firebase::firestore::model::DocumentKeySet)addedDocuments + modifiedDocuments:(firebase::firestore::model::DocumentKeySet)modifiedDocuments + removedDocuments:(firebase::firestore::model::DocumentKeySet)removedDocuments + NS_DESIGNATED_INITIALIZER; + +- (instancetype)init NS_UNAVAILABLE; /** - * Creates a new target change with the given documents. Instances of FSTDocument are considered - * added. Instance of FSTDeletedDocument are considered removed. This is intended primarily for - * testing. + * An opaque, server-assigned token that allows watching a query to be resumed after + * disconnecting without retransmitting all the data that matches the query. The resume token + * essentially identifies a point in time from which the server should resume sending results. */ -+ (instancetype)changeWithDocuments:(NSArray *)docs - currentStatusUpdate:(FSTCurrentStatusUpdate)currentStatusUpdate; +@property(nonatomic, strong, readonly) NSData *resumeToken; /** - * The snapshot version representing the last state at which this target received a consistent - * snapshot from the backend. + * The "current" (synced) status of this target. Note that "current" has special meaning in the RPC + * protocol that implies that a target is both up-to-date and consistent with the rest of the watch + * stream. */ -- (const firebase::firestore::model::SnapshotVersion &)snapshotVersion; +@property(nonatomic, assign, readonly) BOOL current; /** - * The new "current" (synced) status of this target. Set to CurrentStatusUpdateNone if the status - * should not be updated. Note "current" has special meaning for in the RPC protocol that implies - * that a target is both up-to-date and consistent with the rest of the watch stream. + * The set of documents that were newly assigned to this target as part of this remote event. */ -@property(nonatomic, assign, readonly) FSTCurrentStatusUpdate currentStatusUpdate; +- (const firebase::firestore::model::DocumentKeySet &)addedDocuments; -/** A set of changes to documents in this target. */ -@property(nonatomic, strong, readonly) FSTTargetMapping *mapping; +/** + * The set of documents that were already assigned to this target but received an update during this + * remote event. + */ +- (const firebase::firestore::model::DocumentKeySet &)modifiedDocuments; /** - * An opaque, server-assigned token that allows watching a query to be resumed after disconnecting - * without retransmitting all the data that matches the query. The resume token essentially - * identifies a point in time from which the server should resume sending results. + * The set of documents that were removed from this target as part of this remote event. */ -@property(nonatomic, strong, readonly) NSData *resumeToken; +- (const firebase::firestore::model::DocumentKeySet &)removedDocuments; @end @@ -165,34 +122,38 @@ typedef NS_ENUM(NSUInteger, FSTCurrentStatusUpdate) { - (instancetype) initWithSnapshotVersion:(firebase::firestore::model::SnapshotVersion)snapshotVersion - targetChanges:(NSMutableDictionary *)targetChanges + targetChanges:(std::unordered_map)targetChanges + targetMismatches:(std::unordered_set)targetMismatches documentUpdates: - (std::map)documentUpdates + (std::unordered_map)documentUpdates limboDocuments:(firebase::firestore::model::DocumentKeySet)limboDocuments; /** The snapshot version this event brings us up to. */ - (const firebase::firestore::model::SnapshotVersion &)snapshotVersion; -/** A map from target to changes to the target. See TargetChange. */ -@property(nonatomic, strong, readonly) - NSDictionary *targetChanges; - /** - * A set of which documents have changed or been deleted, along with the doc's new values - * (if not deleted). + * A set of which document updates are due only to limbo resolution targets. */ -- (const std::map &)documentUpdates; - - (const firebase::firestore::model::DocumentKeySet &)limboDocumentChanges; -/** Adds a document update to this remote event */ -- (void)addDocumentUpdate:(FSTMaybeDocument *)document; +/** A map from target to changes to the target. See TargetChange. */ +- (const std::unordered_map &)targetChanges; -/** Handles an existence filter mismatch */ -- (void)handleExistenceFilterMismatchForTargetID:(FSTBoxedTargetID *)targetID; +/** + * A set of targets that is known to be inconsistent. Listens for these targets should be + * re-established without resume tokens. + */ +- (const std::unordered_set &)targetMismatches; -- (void)synthesizeDeleteForLimboTargetChange:(FSTTargetChange *)targetChange - key:(const firebase::firestore::model::DocumentKey &)key; +/** + * A set of which documents have changed or been deleted, along with the doc's new values (if not + * deleted). + */ +- (const std::unordered_map &)documentUpdates; @end @@ -204,33 +165,35 @@ initWithSnapshotVersion:(firebase::firestore::model::SnapshotVersion)snapshotVer */ @interface FSTWatchChangeAggregator : NSObject -- (instancetype) -initWithSnapshotVersion:(firebase::firestore::model::SnapshotVersion)snapshotVersion - listenTargets:(NSDictionary *)listenTargets - pendingTargetResponses:(NSDictionary *)pendingTargetResponses +- (instancetype)initWithTargetMetadataProvider:(id)targetMetadataProvider NS_DESIGNATED_INITIALIZER; - (instancetype)init NS_UNAVAILABLE; -/** The number of pending responses that are being waited on from watch */ -@property(nonatomic, strong, readonly) - NSMutableDictionary *pendingTargetResponses; +/** Processes and adds the FSTDocumentWatchChange to the current set of changes. */ +- (void)handleDocumentChange:(FSTDocumentWatchChange *)documentChange; -/** Aggregates a watch change into the current state */ -- (void)addWatchChange:(FSTWatchChange *)watchChange; +/** Processes and adds the WatchTargetChange to the current set of changes. */ +- (void)handleTargetChange:(FSTWatchTargetChange *)targetChange; -/** Aggregates all provided watch changes to the current state in order */ -- (void)addWatchChanges:(NSArray *)watchChanges; +/** + * Handles existence filters and synthesizes deletes for filter mismatches. Targets that are + * invalidated by filter mismatches are added to `targetMismatches`. + */ +- (void)handleExistenceFilter:(FSTExistenceFilterWatchChange *)existenceFilter; + +/** + * Increment the number of acks needed from watch before we can consider the server to be 'in-sync' + * with the client's active targets. + */ +- (void)recordTargetRequest:(FSTBoxedTargetID *)targetID; /** * Converts the current state into a remote event with the snapshot version taken from the * initializer. */ -- (FSTRemoteEvent *)remoteEvent; - -/** The existence filters - if any - for the given target IDs. */ -@property(nonatomic, strong, readonly) - NSDictionary *existenceFilters; +- (FSTRemoteEvent *)remoteEventAtSnapshotVersion: + (const firebase::firestore::model::SnapshotVersion &)snapshotVersion; @end diff --git a/Firestore/Source/Remote/FSTRemoteEvent.mm b/Firestore/Source/Remote/FSTRemoteEvent.mm index 67af650..5e00310 100644 --- a/Firestore/Source/Remote/FSTRemoteEvent.mm +++ b/Firestore/Source/Remote/FSTRemoteEvent.mm @@ -17,10 +17,16 @@ #import "Firestore/Source/Remote/FSTRemoteEvent.h" #include +#include +#include #include +#import "Firestore/Source/Core/FSTQuery.h" +#import "Firestore/Source/Core/FSTViewSnapshot.h" #import "Firestore/Source/Local/FSTQueryData.h" #import "Firestore/Source/Model/FSTDocument.h" +#import "Firestore/Source/Remote/FSTExistenceFilter.h" +#import "Firestore/Source/Remote/FSTRemoteStore.h" #import "Firestore/Source/Remote/FSTWatchChange.h" #import "Firestore/Source/Util/FSTClasses.h" @@ -30,619 +36,578 @@ #include "Firestore/core/src/firebase/firestore/util/log.h" using firebase::firestore::model::DocumentKey; +using firebase::firestore::model::DocumentKeyHash; +using firebase::firestore::model::DocumentKeySet; using firebase::firestore::model::SnapshotVersion; using firebase::firestore::util::Hash; -using firebase::firestore::model::DocumentKeySet; NS_ASSUME_NONNULL_BEGIN -#pragma mark - FSTTargetMapping - -@interface FSTTargetMapping () - -/** Private mutator method to add a document key to the mapping */ -- (void)addDocumentKey:(const DocumentKey &)documentKey; - -/** Private mutator method to remove a document key from the mapping */ -- (void)removeDocumentKey:(const DocumentKey &)documentKey; - -@end - -@implementation FSTTargetMapping - -- (void)addDocumentKey:(const DocumentKey &)documentKey { - @throw FSTAbstractMethodException(); // NOLINT -} +#pragma mark - FSTTargetChange -- (void)removeDocumentKey:(const DocumentKey &)documentKey { - @throw FSTAbstractMethodException(); // NOLINT +@implementation FSTTargetChange { + DocumentKeySet _addedDocuments; + DocumentKeySet _modifiedDocuments; + DocumentKeySet _removedDocuments; } -- (void)filterUpdatesUsingExistingKeys:(const DocumentKeySet &)existingKeys { - @throw FSTAbstractMethodException(); // NOLINT +- (instancetype)initWithResumeToken:(NSData *)resumeToken + current:(BOOL)current + addedDocuments:(DocumentKeySet)addedDocuments + modifiedDocuments:(DocumentKeySet)modifiedDocuments + removedDocuments:(DocumentKeySet)removedDocuments { + if (self = [super init]) { + _resumeToken = [resumeToken copy]; + _current = current; + _addedDocuments = std::move(addedDocuments); + _modifiedDocuments = std::move(modifiedDocuments); + _removedDocuments = std::move(removedDocuments); + } + return self; } -@end - -#pragma mark - FSTResetMapping - -@implementation FSTResetMapping { - DocumentKeySet _documents; +- (const DocumentKeySet &)addedDocuments { + return _addedDocuments; } -+ (instancetype)mappingWithDocuments:(NSArray *)documents { - DocumentKeySet keys; - for (FSTDocument *doc in documents) { - keys = keys.insert(doc.key); - } - return [[FSTResetMapping alloc] initWithDocuments:std::move(keys)]; +- (const DocumentKeySet &)modifiedDocuments { + return _modifiedDocuments; } -- (instancetype)initWithDocuments:(DocumentKeySet)documents { - self = [super init]; - if (self) { - _documents = std::move(documents); - } - return self; -} - -- (const DocumentKeySet &)documents { - return _documents; +- (const DocumentKeySet &)removedDocuments { + return _removedDocuments; } - (BOOL)isEqual:(id)other { if (other == self) { return YES; } - if (![other isMemberOfClass:[FSTResetMapping class]]) { + if (![other isMemberOfClass:[FSTTargetChange class]]) { return NO; } - FSTResetMapping *otherMapping = (FSTResetMapping *)other; - return _documents == otherMapping.documents; + return [self current] == [other current] && + [[self resumeToken] isEqualToData:[other resumeToken]] && + [self addedDocuments] == [other addedDocuments] && + [self modifiedDocuments] == [other modifiedDocuments] && + [self removedDocuments] == [other removedDocuments]; } -- (NSUInteger)hash { - return Hash(_documents); -} +@end -- (void)addDocumentKey:(const DocumentKey &)documentKey { - _documents = _documents.insert(documentKey); -} +#pragma mark - FSTTargetState -- (void)removeDocumentKey:(const DocumentKey &)documentKey { - _documents = _documents.erase(documentKey); -} +/** Tracks the internal state of a Watch target. */ +@interface FSTTargetState : NSObject -- (void)filterUpdatesUsingExistingKeys:(const DocumentKeySet &)existingKeys { - // No-op. Resets are not filtered. -} +/** + * Whether this target has been marked 'current'. + * + * 'Current' has special meaning in the RPC protocol: It implies that the Watch backend has sent us + * all changes up to the point at which the target was added and that the target is consistent with + * the rest of the watch stream. + */ +@property(nonatomic) BOOL current; -@end +/** The last resume token sent to us for this target. */ +@property(nonatomic, readonly, strong) NSData *resumeToken; -#pragma mark - FSTUpdateMapping +/** Whether we have modified any state that should trigger a snapshot. */ +@property(nonatomic, readonly) BOOL hasPendingChanges; -@implementation FSTUpdateMapping { - DocumentKeySet _addedDocuments; - DocumentKeySet _removedDocuments; -} +/** Whether this target has pending target adds or target removes. */ +- (BOOL)isPending; -+ (FSTUpdateMapping *)mappingWithAddedDocuments:(NSArray *)added - removedDocuments:(NSArray *)removed { - DocumentKeySet addedDocuments; - DocumentKeySet removedDocuments; - for (FSTDocument *doc in added) { - addedDocuments = addedDocuments.insert(doc.key); - } - for (FSTDocument *doc in removed) { - removedDocuments = removedDocuments.insert(doc.key); - } - return [[FSTUpdateMapping alloc] initWithAddedDocuments:std::move(addedDocuments) - removedDocuments:std::move(removedDocuments)]; -} +/** + * Applies the resume token to the TargetChange, but only when it has a new value. Empty + * resumeTokens are discarded. + */ +- (void)updateResumeToken:(NSData *)resumeToken; -- (instancetype)initWithAddedDocuments:(DocumentKeySet)addedDocuments - removedDocuments:(DocumentKeySet)removedDocuments { - self = [super init]; - if (self) { - _addedDocuments = std::move(addedDocuments); - _removedDocuments = std::move(removedDocuments); - } - return self; -} +/** Resets the document changes and sets `hasPendingChanges` to false. */ +- (void)clearPendingChanges; +/** + * Creates a target change from the current set of changes. + * + * To reset the document changes after raising this snapshot, call `clearPendingChanges()`. + */ +- (FSTTargetChange *)toTargetChange; -- (const DocumentKeySet &)addedDocuments { - return _addedDocuments; -} +- (void)recordTargetRequest; +- (void)recordTargetResponse; +- (void)markCurrent; +- (void)addDocumentChangeWithType:(FSTDocumentViewChangeType)type + forKey:(const DocumentKey &)documentKey; +- (void)removeDocumentChangeForKey:(const DocumentKey &)documentKey; -- (const DocumentKeySet &)removedDocuments { - return _removedDocuments; -} +@end -- (BOOL)isEqual:(id)other { - if (other == self) { - return YES; - } - if (![other isMemberOfClass:[FSTUpdateMapping class]]) { - return NO; - } +@implementation FSTTargetState { + /** + * The number of outstanding responses (adds or removes) that we are waiting on. We only consider + * targets active that have no outstanding responses. + */ + int _outstandingResponses; - FSTUpdateMapping *otherMapping = (FSTUpdateMapping *)other; - return _addedDocuments == otherMapping.addedDocuments && - _removedDocuments == otherMapping.removedDocuments; + /** + * Keeps track of the document changes since the last raised snapshot. + * + * These changes are continuously updated as we receive document updates and always reflect the + * current set of changes against the last issued snapshot. + */ + std::unordered_map _documentChanges; } -- (NSUInteger)hash { - return Hash(_addedDocuments, _removedDocuments); -} +- (instancetype)init { + if (self = [super init]) { + _resumeToken = [NSData data]; + _outstandingResponses = 0; -- (DocumentKeySet)applyTo:(const DocumentKeySet &)keys { - DocumentKeySet result = keys; - for (const DocumentKey &key : _addedDocuments) { - result = result.insert(key); + // We initialize to 'true' so that newly-added targets are included in the next RemoteEvent. + _hasPendingChanges = YES; } - for (const DocumentKey &key : _removedDocuments) { - result = result.erase(key); - } - return result; -} - -- (void)addDocumentKey:(const DocumentKey &)documentKey { - _addedDocuments = _addedDocuments.insert(documentKey); - _removedDocuments = _removedDocuments.erase(documentKey); + return self; } -- (void)removeDocumentKey:(const DocumentKey &)documentKey { - _addedDocuments = _addedDocuments.erase(documentKey); - _removedDocuments = _removedDocuments.insert(documentKey); +- (BOOL)isPending { + return _outstandingResponses != 0; } -- (void)filterUpdatesUsingExistingKeys:(const DocumentKeySet &)existingKeys { - DocumentKeySet result = _addedDocuments; - for (const DocumentKey &key : _addedDocuments) { - if (existingKeys.contains(key)) { - result = result.erase(key); - } +- (void)updateResumeToken:(NSData *)resumeToken { + if (resumeToken.length > 0) { + _hasPendingChanges = YES; + _resumeToken = [resumeToken copy]; } - _addedDocuments = result; } -@end - -#pragma mark - FSTTargetChange +- (void)clearPendingChanges { + _hasPendingChanges = NO; + _documentChanges.clear(); +} -@interface FSTTargetChange () -@property(nonatomic, assign) FSTCurrentStatusUpdate currentStatusUpdate; -@property(nonatomic, strong, nullable) FSTTargetMapping *mapping; -@property(nonatomic, strong) NSData *resumeToken; -@end +- (void)recordTargetRequest { + _outstandingResponses += 1; +} -@implementation FSTTargetChange { - SnapshotVersion _snapshotVersion; +- (void)recordTargetResponse { + _outstandingResponses -= 1; } -- (instancetype)init { - if (self = [super init]) { - _currentStatusUpdate = FSTCurrentStatusUpdateNone; - _resumeToken = [NSData data]; - } - return self; +- (void)markCurrent { + _hasPendingChanges = YES; + _current = true; } -- (instancetype)initWithSnapshotVersion:(SnapshotVersion)snapshotVersion { - if (self = [self init]) { - _snapshotVersion = std::move(snapshotVersion); - } - return self; +- (void)addDocumentChangeWithType:(FSTDocumentViewChangeType)type + forKey:(const DocumentKey &)documentKey { + _hasPendingChanges = YES; + _documentChanges[documentKey] = type; } -- (const SnapshotVersion &)snapshotVersion { - return _snapshotVersion; +- (void)removeDocumentChangeForKey:(const DocumentKey &)documentKey { + _hasPendingChanges = YES; + _documentChanges.erase(documentKey); } -+ (instancetype)changeWithDocuments:(NSArray *)docs - currentStatusUpdate:(FSTCurrentStatusUpdate)currentStatusUpdate { +- (FSTTargetChange *)toTargetChange { DocumentKeySet addedDocuments; + DocumentKeySet modifiedDocuments; DocumentKeySet removedDocuments; - for (FSTMaybeDocument *doc in docs) { - if ([doc isKindOfClass:[FSTDeletedDocument class]]) { - removedDocuments = removedDocuments.insert(doc.key); - } else { - addedDocuments = addedDocuments.insert(doc.key); + + for (const auto &entry : _documentChanges) { + switch (entry.second) { + case FSTDocumentViewChangeTypeAdded: + addedDocuments = addedDocuments.insert(entry.first); + break; + case FSTDocumentViewChangeTypeModified: + modifiedDocuments = modifiedDocuments.insert(entry.first); + break; + case FSTDocumentViewChangeTypeRemoved: + removedDocuments = removedDocuments.insert(entry.first); + break; + default: + HARD_FAIL("Encountered invalid change type: %s", entry.second); } } - FSTUpdateMapping *mapping = - [[FSTUpdateMapping alloc] initWithAddedDocuments:std::move(addedDocuments) - removedDocuments:std::move(removedDocuments)]; - - FSTTargetChange *change = [[FSTTargetChange alloc] init]; - change.mapping = mapping; - change.currentStatusUpdate = currentStatusUpdate; - return change; -} - -+ (instancetype)changeWithMapping:(FSTTargetMapping *)mapping - snapshotVersion:(SnapshotVersion)snapshotVersion - currentStatusUpdate:(FSTCurrentStatusUpdate)currentStatusUpdate { - FSTTargetChange *change = [[FSTTargetChange alloc] init]; - change.mapping = mapping; - change->_snapshotVersion = std::move(snapshotVersion); - change.currentStatusUpdate = currentStatusUpdate; - return change; -} - -- (FSTTargetMapping *)mapping { - if (!_mapping) { - // Create an FSTUpdateMapping by default, since resets are always explicit - _mapping = [[FSTUpdateMapping alloc] init]; - } - return _mapping; -} -/** - * Sets the resume token but only when it has a new value. Empty resumeTokens are - * discarded. - */ -- (void)setResumeToken:(NSData *)resumeToken { - if (resumeToken.length > 0) { - _resumeToken = resumeToken; - } + return [[FSTTargetChange alloc] initWithResumeToken:_resumeToken + current:_current + addedDocuments:std::move(addedDocuments) + modifiedDocuments:std::move(modifiedDocuments) + removedDocuments:std::move(removedDocuments)]; } - @end #pragma mark - FSTRemoteEvent @implementation FSTRemoteEvent { SnapshotVersion _snapshotVersion; - NSMutableDictionary *_targetChanges; - std::map _documentUpdates; + std::unordered_map _targetChanges; + std::unordered_set _targetMismatches; + std::unordered_map _documentUpdates; DocumentKeySet _limboDocumentChanges; } -- (instancetype)initWithSnapshotVersion:(SnapshotVersion)snapshotVersion - targetChanges: - (NSMutableDictionary *)targetChanges - documentUpdates:(std::map)documentUpdates - limboDocuments:(DocumentKeySet)limboDocuments { +- (instancetype) +initWithSnapshotVersion:(SnapshotVersion)snapshotVersion + targetChanges:(std::unordered_map)targetChanges + targetMismatches:(std::unordered_set)targetMismatches + documentUpdates: + (std::unordered_map)documentUpdates + limboDocuments:(DocumentKeySet)limboDocuments { self = [super init]; if (self) { _snapshotVersion = std::move(snapshotVersion); - _targetChanges = targetChanges; + _targetChanges = std::move(targetChanges); + _targetMismatches = std::move(targetMismatches); _documentUpdates = std::move(documentUpdates); _limboDocumentChanges = std::move(limboDocuments); } return self; } -- (NSDictionary *)targetChanges { - return _targetChanges; +- (const SnapshotVersion &)snapshotVersion { + return _snapshotVersion; } - (const DocumentKeySet &)limboDocumentChanges { return _limboDocumentChanges; } -- (const std::map &)documentUpdates { - return _documentUpdates; -} - -- (const SnapshotVersion &)snapshotVersion { - return _snapshotVersion; -} - -- (void)synthesizeDeleteForLimboTargetChange:(FSTTargetChange *)targetChange - key:(const DocumentKey &)key { - if (targetChange.currentStatusUpdate == FSTCurrentStatusUpdateMarkCurrent && - _documentUpdates.find(key) == _documentUpdates.end()) { - // When listening to a query the server responds with a snapshot containing documents - // matching the query and a current marker telling us we're now in sync. It's possible for - // these to arrive as separate remote events or as a single remote event. For a document - // query, there will be no documents sent in the response if the document doesn't exist. - // - // If the snapshot arrives separately from the current marker, we handle it normally and - // updateTrackedLimboDocumentsWithChanges:targetID: will resolve the limbo status of the - // document, removing it from limboDocumentRefs. This works because clients only initiate - // limbo resolution when a target is current and because all current targets are always at a - // consistent snapshot. - // - // However, if the document doesn't exist and the current marker arrives, the document is - // not present in the snapshot and our normal view handling would consider the document to - // remain in limbo indefinitely because there are no updates to the document. To avoid this, - // we specially handle this case here: synthesizing a delete. - // - // TODO(dimond): Ideally we would have an explicit lookup query instead resulting in an - // explicit delete message and we could remove this special logic. - _documentUpdates[key] = [FSTDeletedDocument documentWithKey:key version:_snapshotVersion]; - _limboDocumentChanges = _limboDocumentChanges.insert(key); - } +- (const std::unordered_map &)targetChanges { + return _targetChanges; } -/** Adds a document update to this remote event */ -- (void)addDocumentUpdate:(FSTMaybeDocument *)document { - _documentUpdates[document.key] = document; +- (const std::unordered_map &)documentUpdates { + return _documentUpdates; } -/** Handles an existence filter mismatch */ -- (void)handleExistenceFilterMismatchForTargetID:(FSTBoxedTargetID *)targetID { - // An existence filter mismatch will reset the query and we need to reset the mapping to contain - // no documents and an empty resume token. - // - // Note: - // * The reset mapping is empty, specifically forcing the consumer of the change to - // forget all keys for this targetID; - // * The resume snapshot for this target must be reset - // * The target must be unacked because unwatching and rewatching introduces a race for - // changes. - // - // TODO(dimond): keep track of reset targets not to raise. - FSTTargetChange *targetChange = - [FSTTargetChange changeWithMapping:[[FSTResetMapping alloc] init] - snapshotVersion:SnapshotVersion::None() - currentStatusUpdate:FSTCurrentStatusUpdateMarkNotCurrent]; - _targetChanges[targetID] = targetChange; +- (const std::unordered_set &)targetMismatches { + return _targetMismatches; } @end #pragma mark - FSTWatchChangeAggregator -@interface FSTWatchChangeAggregator () - -/** Keeps track of the current target mappings */ -@property(nonatomic, strong, readonly) - NSMutableDictionary *targetChanges; - -/** The set of open listens on the client */ -@property(nonatomic, strong, readonly) - NSDictionary *listenTargets; +@implementation FSTWatchChangeAggregator { + /** The internal state of all tracked targets. */ + std::unordered_map _targetStates; -/** Whether this aggregator was frozen and can no longer be modified */ -@property(nonatomic, assign) BOOL frozen; + /** Keeps track of document to update */ + std::unordered_map _pendingDocumentUpdates; -@end + /** A mapping of document keys to their set of target IDs. */ + std::unordered_map, DocumentKeyHash> + _pendingDocumentTargetMappings; -@implementation FSTWatchChangeAggregator { - NSMutableDictionary *_existenceFilters; - /** Keeps track of document to update */ - std::map _documentUpdates; + /** + * A list of targets with existence filter mismatches. These targets are known to be inconsistent + * and their listens needs to be re-established by RemoteStore. + */ + std::unordered_set _pendingTargetResets; - DocumentKeySet _limboDocuments; - /** The snapshot version for every target change this creates. */ - SnapshotVersion _snapshotVersion; + id _targetMetadataProvider; } -- (instancetype) -initWithSnapshotVersion:(SnapshotVersion)snapshotVersion - listenTargets:(NSDictionary *)listenTargets - pendingTargetResponses:(NSDictionary *)pendingTargetResponses { +- (instancetype)initWithTargetMetadataProvider: + (id)targetMetadataProvider { self = [super init]; if (self) { - _snapshotVersion = std::move(snapshotVersion); - - _frozen = NO; - _targetChanges = [NSMutableDictionary dictionary]; - _listenTargets = listenTargets; - _pendingTargetResponses = [NSMutableDictionary dictionaryWithDictionary:pendingTargetResponses]; - _limboDocuments = DocumentKeySet{}; - _existenceFilters = [NSMutableDictionary dictionary]; + _targetMetadataProvider = targetMetadataProvider; } return self; } -- (NSDictionary *)existenceFilters { - return _existenceFilters; -} - -- (FSTTargetChange *)targetChangeForTargetID:(FSTBoxedTargetID *)targetID { - FSTTargetChange *change = self.targetChanges[targetID]; - if (!change) { - change = [[FSTTargetChange alloc] initWithSnapshotVersion:_snapshotVersion]; - self.targetChanges[targetID] = change; - } - return change; -} - -- (void)addWatchChanges:(NSArray *)watchChanges { - HARD_ASSERT(!self.frozen, "Trying to modify frozen FSTWatchChangeAggregator"); - for (FSTWatchChange *watchChange in watchChanges) { - [self addWatchChange:watchChange]; - } -} - -- (void)addWatchChange:(FSTWatchChange *)watchChange { - HARD_ASSERT(!self.frozen, "Trying to modify frozen FSTWatchChangeAggregator"); - if ([watchChange isKindOfClass:[FSTDocumentWatchChange class]]) { - [self addDocumentChange:(FSTDocumentWatchChange *)watchChange]; - } else if ([watchChange isKindOfClass:[FSTWatchTargetChange class]]) { - [self addTargetChange:(FSTWatchTargetChange *)watchChange]; - } else if ([watchChange isKindOfClass:[FSTExistenceFilterWatchChange class]]) { - [self addExistenceFilterChange:(FSTExistenceFilterWatchChange *)watchChange]; - } else { - HARD_FAIL("Unknown watch change: %s", watchChange); - } -} - -/** - * Updates limbo document tracking for a given target-document mapping change. If the target is a - * limbo target, and the change for the document has only seen limbo targets so far, and we are not - * already tracking a change for this document, then consider this document a limbo document update. - * Otherwise, ensure that we don't consider this document a limbo document. Returns true if the - * change still has only seen limbo resolution changes. - */ -- (BOOL)updateLimboDocumentsForKey:(const DocumentKey &)documentKey - queryData:(FSTQueryData *)queryData - isOnlyLimbo:(BOOL)isOnlyLimbo { - if (!isOnlyLimbo) { - // It wasn't a limbo doc before, so it definitely isn't now. - return NO; - } - if (_documentUpdates.find(documentKey) == _documentUpdates.end()) { - // We haven't seen a document update for this key yet. - if (queryData.purpose == FSTQueryPurposeLimboResolution) { - // We haven't seen this document before, and this target is a limbo target. - _limboDocuments = _limboDocuments.insert(documentKey); - return YES; - } else { - // We haven't seen the document before, but this is a non-limbo target. - // Since we haven't seen it, we know it's not in our set of limbo docs. Return NO to ensure - // that this key is marked as non-limbo. - return NO; - } - } else if (queryData.purpose == FSTQueryPurposeLimboResolution) { - // We have only seen limbo targets so far for this document, and this is another limbo target. - return YES; - } else { - // We haven't marked this as non-limbo yet, but this target is not a limbo target. - // Mark the key as non-limbo and make sure it isn't in our set. - _limboDocuments = _limboDocuments.erase(documentKey); - return NO; - } -} - -- (void)addDocumentChange:(FSTDocumentWatchChange *)docChange { - BOOL relevant = NO; - BOOL isOnlyLimbo = YES; - - for (FSTBoxedTargetID *targetID in docChange.updatedTargetIDs) { - FSTQueryData *queryData = [self queryDataForActiveTarget:targetID]; - if (queryData) { - FSTTargetChange *change = [self targetChangeForTargetID:targetID]; - isOnlyLimbo = [self updateLimboDocumentsForKey:docChange.documentKey - queryData:queryData - isOnlyLimbo:isOnlyLimbo]; - [change.mapping addDocumentKey:docChange.documentKey]; - relevant = YES; - } - } - - for (FSTBoxedTargetID *targetID in docChange.removedTargetIDs) { - FSTQueryData *queryData = [self queryDataForActiveTarget:targetID]; - if (queryData) { - FSTTargetChange *change = [self targetChangeForTargetID:targetID]; - isOnlyLimbo = [self updateLimboDocumentsForKey:docChange.documentKey - queryData:queryData - isOnlyLimbo:isOnlyLimbo]; - [change.mapping removeDocumentKey:docChange.documentKey]; - relevant = YES; +- (void)handleDocumentChange:(FSTDocumentWatchChange *)documentChange { + for (FSTBoxedTargetID *targetID in documentChange.updatedTargetIDs) { + if ([documentChange.document isKindOfClass:[FSTDocument class]]) { + [self addDocument:documentChange.document toTarget:targetID.intValue]; + } else if ([documentChange.document isKindOfClass:[FSTDeletedDocument class]]) { + [self removeDocument:documentChange.document + withKey:documentChange.documentKey + fromTarget:targetID.intValue]; } } - // Only update the document if there is a new document to replace, this might be just a target - // update instead. - if (docChange.document && relevant) { - _documentUpdates[docChange.documentKey] = docChange.document; + for (FSTBoxedTargetID *targetID in documentChange.removedTargetIDs) { + [self removeDocument:documentChange.document + withKey:documentChange.documentKey + fromTarget:targetID.intValue]; } } -- (void)addTargetChange:(FSTWatchTargetChange *)targetChange { - for (FSTBoxedTargetID *targetID in targetChange.targetIDs) { - FSTTargetChange *change = [self targetChangeForTargetID:targetID]; +- (void)handleTargetChange:(FSTWatchTargetChange *)targetChange { + for (FSTBoxedTargetID *boxedTargetID in targetChange.targetIDs) { + int targetID = boxedTargetID.intValue; + FSTTargetState *targetState = [self ensureTargetStateForTarget:targetID]; switch (targetChange.state) { case FSTWatchTargetChangeStateNoChange: if ([self isActiveTarget:targetID]) { - // Creating the change above satisfies the semantics of no-change. - change.resumeToken = targetChange.resumeToken; + [targetState updateResumeToken:targetChange.resumeToken]; } break; case FSTWatchTargetChangeStateAdded: - [self recordResponseForTargetID:targetID]; - if (!self.pendingTargetResponses[targetID]) { - // We have a freshly added target, so we need to reset any state that we had previously - // This can happen e.g. when remove and add back a target for existence filter - // mismatches. - change.mapping = nil; - change.currentStatusUpdate = FSTCurrentStatusUpdateNone; - [_existenceFilters removeObjectForKey:targetID]; + // We need to decrement the number of pending acks needed from watch for this targetId. + [targetState recordTargetResponse]; + if (!targetState.isPending) { + // We have a freshly added target, so we need to reset any state that we had previously. + // This can happen e.g. when remove and add back a target for existence filter mismatches. + [targetState clearPendingChanges]; } - change.resumeToken = targetChange.resumeToken; + [targetState updateResumeToken:targetChange.resumeToken]; break; case FSTWatchTargetChangeStateRemoved: // We need to keep track of removed targets to we can post-filter and remove any target // changes. - [self recordResponseForTargetID:targetID]; - HARD_ASSERT(!targetChange.cause, "WatchChangeAggregator does not handle errored targets."); + [targetState recordTargetResponse]; + if (!targetState.isPending) { + [self removeTarget:targetID]; + } + HARD_ASSERT(!targetChange.cause, "WatchChangeAggregator does not handle errored targets"); break; case FSTWatchTargetChangeStateCurrent: if ([self isActiveTarget:targetID]) { - change.currentStatusUpdate = FSTCurrentStatusUpdateMarkCurrent; - change.resumeToken = targetChange.resumeToken; + [targetState markCurrent]; + [targetState updateResumeToken:targetChange.resumeToken]; } break; case FSTWatchTargetChangeStateReset: if ([self isActiveTarget:targetID]) { - // Overwrite any existing target mapping with a reset mapping. Every subsequent update - // will modify the reset mapping, not an update mapping. - change.mapping = [[FSTResetMapping alloc] init]; - change.resumeToken = targetChange.resumeToken; + // Reset the target and synthesizes removes for all existing documents. The backend will + // re-add any documents that still match the target before it sends the next global + // snapshot. + [self resetTarget:targetID]; + [targetState updateResumeToken:targetChange.resumeToken]; } break; default: - LOG_WARN("Unknown target watch change type: %s", targetChange.state); + HARD_FAIL("Unknown target watch change state: %s", targetChange.state); } } } +- (void)removeTarget:(FSTTargetID)targetID { + _targetStates.erase(targetID); +} + +- (void)handleExistenceFilter:(FSTExistenceFilterWatchChange *)existenceFilter { + FSTTargetID targetID = existenceFilter.targetID; + int expectedCount = existenceFilter.filter.count; + + FSTQueryData *queryData = [self queryDataForActiveTarget:targetID]; + if (queryData) { + FSTQuery *query = queryData.query; + if ([query isDocumentQuery]) { + if (expectedCount == 0) { + // The existence filter told us the document does not exist. We deduce that this document + // does not exist and apply a deleted document to our updates. Without applying this deleted + // document there might be another query that will raise this document as part of a snapshot + // until it is resolved, essentially exposing inconsistency between queries. + FSTDocumentKey *key = [FSTDocumentKey keyWithPath:query.path]; + [self + removeDocument:[FSTDeletedDocument documentWithKey:key version:SnapshotVersion::None()] + withKey:key + fromTarget:targetID]; + } else { + HARD_ASSERT(expectedCount == 1, "Single document existence filter with count: %s", + expectedCount); + } + } else { + int currentSize = [self currentDocumentCountForTarget:targetID]; + if (currentSize != expectedCount) { + // Existence filter mismatch: We reset the mapping and raise a new snapshot with + // `isFromCache:true`. + [self resetTarget:targetID]; + _pendingTargetResets.insert(targetID); + } + } + } +} + +- (int)currentDocumentCountForTarget:(FSTTargetID)targetID { + FSTTargetState *targetState = [self ensureTargetStateForTarget:targetID]; + FSTTargetChange *targetChange = [targetState toTargetChange]; + return ([_targetMetadataProvider remoteKeysForTarget:@(targetID)].size() + + targetChange.addedDocuments.size() - targetChange.removedDocuments.size()); +} + /** - * Records that we got a watch target add/remove by decrementing the number of pending target - * responses that we have. + * Resets the state of a Watch target to its initial state (e.g. sets 'current' to false, clears the + * resume token and removes its target mapping from all documents). */ -- (void)recordResponseForTargetID:(FSTBoxedTargetID *)targetID { - NSNumber *count = self.pendingTargetResponses[targetID]; - int newCount = count ? [count intValue] - 1 : -1; - if (newCount == 0) { - [self.pendingTargetResponses removeObjectForKey:targetID]; +- (void)resetTarget:(FSTTargetID)targetID { + auto currentTargetState = _targetStates.find(targetID); + HARD_ASSERT(currentTargetState != _targetStates.end() && !(currentTargetState->second.isPending), + "Should only reset active targets"); + + _targetStates[targetID] = [FSTTargetState new]; + + // Trigger removal for any documents currently mapped to this target. These removals will be part + // of the initial snapshot if Watch does not resend these documents. + DocumentKeySet existingKeys = [_targetMetadataProvider remoteKeysForTarget:@(targetID)]; + + for (FSTDocumentKey *key : existingKeys) { + [self removeDocument:nil withKey:key fromTarget:targetID]; + } +} + +/** + * Adds the provided document to the internal list of document updates and its document key to the + * given target's mapping. + */ +- (void)addDocument:(FSTMaybeDocument *)document toTarget:(FSTTargetID)targetID { + if (![self isActiveTarget:targetID]) { + return; + } + + FSTDocumentViewChangeType changeType = [self containsDocument:document.key inTarget:targetID] + ? FSTDocumentViewChangeTypeModified + : FSTDocumentViewChangeTypeAdded; + + FSTTargetState *targetState = [self ensureTargetStateForTarget:targetID]; + [targetState addDocumentChangeWithType:changeType forKey:document.key]; + + _pendingDocumentUpdates[document.key] = document; + _pendingDocumentTargetMappings[document.key].insert(targetID); +} + +/** + * Removes the provided document from the target mapping. If the document no longer matches the + * target, but the document's state is still known (e.g. we know that the document was deleted or we + * received the change that caused the filter mismatch), the new document can be provided to update + * the remote document cache. + */ +- (void)removeDocument:(FSTMaybeDocument *_Nullable)document + withKey:(const DocumentKey &)key + fromTarget:(FSTTargetID)targetID { + if (![self isActiveTarget:targetID]) { + return; + } + + FSTTargetState *targetState = [self ensureTargetStateForTarget:targetID]; + + if ([self containsDocument:key inTarget:targetID]) { + [targetState addDocumentChangeWithType:FSTDocumentViewChangeTypeRemoved forKey:key]; } else { - self.pendingTargetResponses[targetID] = @(newCount); + // The document may have entered and left the target before we raised a snapshot, so we can just + // ignore the change. + [targetState removeDocumentChangeForKey:key]; + } + + _pendingDocumentTargetMappings[key].erase(targetID); + + if (document) { + _pendingDocumentUpdates[key] = document; + } +} + +/** + * Returns whether the LocalStore considers the document to be part of the specified target. + */ +- (BOOL)containsDocument:(FSTDocumentKey *)key inTarget:(FSTTargetID)targetID { + const DocumentKeySet &existingKeys = [_targetMetadataProvider remoteKeysForTarget:@(targetID)]; + return existingKeys.contains(key); +} + +- (FSTTargetState *)ensureTargetStateForTarget:(FSTTargetID)targetID { + if (!_targetStates[targetID]) { + _targetStates[targetID] = [FSTTargetState new]; } + + return _targetStates[targetID]; } /** - * Returns true if the given targetId is active. Active targets are those for which there are no + * Returns YES if the given targetId is active. Active targets are those for which there are no * pending requests to add a listen and are in the current list of targets the client cares about. * * Clients can repeatedly listen and stop listening to targets, so this check is useful in * preventing in preventing race conditions for a target where events arrive but the server hasn't * yet acknowledged the intended change in state. */ -- (BOOL)isActiveTarget:(FSTBoxedTargetID *)targetID { +- (BOOL)isActiveTarget:(FSTTargetID)targetID { return [self queryDataForActiveTarget:targetID] != nil; } -- (FSTQueryData *_Nullable)queryDataForActiveTarget:(FSTBoxedTargetID *)targetID { - FSTQueryData *queryData = self.listenTargets[targetID]; - return (queryData && !self.pendingTargetResponses[targetID]) ? queryData : nil; +- (nullable FSTQueryData *)queryDataForActiveTarget:(FSTTargetID)targetID { + auto targetState = _targetStates.find(targetID); + return targetState != _targetStates.end() && targetState->second.isPending + ? nil + : [_targetMetadataProvider queryDataForTarget:@(targetID)]; } -- (void)addExistenceFilterChange:(FSTExistenceFilterWatchChange *)existenceFilterChange { - FSTBoxedTargetID *targetID = @(existenceFilterChange.targetID); - if ([self isActiveTarget:targetID]) { - _existenceFilters[targetID] = existenceFilterChange.filter; +- (FSTRemoteEvent *)remoteEventAtSnapshotVersion:(const SnapshotVersion &)snapshotVersion { + std::unordered_map targetChanges; + + for (const auto &entry : _targetStates) { + FSTTargetID targetID = entry.first; + FSTTargetState *targetState = entry.second; + + FSTQueryData *queryData = [self queryDataForActiveTarget:targetID]; + if (queryData) { + if (targetState.current && [queryData.query isDocumentQuery]) { + // Document queries for document that don't exist can produce an empty result set. To update + // our local cache, we synthesize a document delete if we have not previously received the + // document. This resolves the limbo state of the document, removing it from + // limboDocumentRefs. + FSTDocumentKey *key = [FSTDocumentKey keyWithPath:queryData.query.path]; + if (_pendingDocumentUpdates.find(key) == _pendingDocumentUpdates.end() && + ![self containsDocument:key inTarget:targetID]) { + [self removeDocument:[FSTDeletedDocument documentWithKey:key version:snapshotVersion] + withKey:key + fromTarget:targetID]; + } + } + + if (targetState.hasPendingChanges) { + targetChanges[targetID] = [targetState toTargetChange]; + [targetState clearPendingChanges]; + } + } } -} -- (FSTRemoteEvent *)remoteEvent { - NSMutableDictionary *targetChanges = self.targetChanges; + DocumentKeySet resolvedLimboDocuments; - NSMutableArray *targetsToRemove = [NSMutableArray array]; + // We extract the set of limbo-only document updates as the GC logic special-cases documents that + // do not appear in the query cache. + // + // TODO(gsoltis): Expand on this comment. + for (const auto &entry : _pendingDocumentTargetMappings) { + BOOL isOnlyLimboTarget = YES; + + for (FSTTargetID targetID : entry.second) { + FSTQueryData *queryData = [self queryDataForActiveTarget:targetID]; + if (queryData && queryData.purpose != FSTQueryPurposeLimboResolution) { + isOnlyLimboTarget = NO; + break; + } + } - // Apply any inactive targets. - for (FSTBoxedTargetID *targetID in [targetChanges keyEnumerator]) { - if (![self isActiveTarget:targetID]) { - [targetsToRemove addObject:targetID]; + if (isOnlyLimboTarget) { + resolvedLimboDocuments = resolvedLimboDocuments.insert(entry.first); } } - [targetChanges removeObjectsForKeys:targetsToRemove]; + FSTRemoteEvent *remoteEvent = + [[FSTRemoteEvent alloc] initWithSnapshotVersion:snapshotVersion + targetChanges:targetChanges + targetMismatches:_pendingTargetResets + documentUpdates:_pendingDocumentUpdates + limboDocuments:resolvedLimboDocuments]; + + _pendingDocumentUpdates.clear(); + _pendingDocumentTargetMappings.clear(); + _pendingTargetResets.clear(); - // Mark this aggregator as frozen so no further modifications are made. - self.frozen = YES; - return [[FSTRemoteEvent alloc] initWithSnapshotVersion:_snapshotVersion - targetChanges:targetChanges - documentUpdates:_documentUpdates - limboDocuments:_limboDocuments]; + return remoteEvent; } +- (void)recordTargetRequest:(FSTBoxedTargetID *)targetID { + // For each request we get we need to record we need a response for it. + FSTTargetState *targetState = [self ensureTargetStateForTarget:targetID.intValue]; + [targetState recordTargetRequest]; +} @end NS_ASSUME_NONNULL_END diff --git a/Firestore/Source/Remote/FSTRemoteStore.h b/Firestore/Source/Remote/FSTRemoteStore.h index 9b01ce4..302d56a 100644 --- a/Firestore/Source/Remote/FSTRemoteStore.h +++ b/Firestore/Source/Remote/FSTRemoteStore.h @@ -17,6 +17,7 @@ #import #import "Firestore/Source/Core/FSTTypes.h" +#import "Firestore/Source/Remote/FSTRemoteEvent.h" #include "Firestore/core/src/firebase/firestore/auth/user.h" @@ -24,7 +25,6 @@ @class FSTLocalStore; @class FSTMutationBatch; @class FSTMutationBatchResult; -@class FSTQuery; @class FSTQueryData; @class FSTRemoteEvent; @class FSTTransaction; @@ -73,6 +73,12 @@ NS_ASSUME_NONNULL_BEGIN */ - (void)rejectFailedWriteWithBatchID:(FSTBatchID)batchID error:(NSError *)error; +/** + * Returns the set of remote document keys for the given target ID. This list includes the + * documents that were assigned to the target when we received the last snapshot. + */ +- (firebase::firestore::model::DocumentKeySet)remoteKeysForTarget:(FSTBoxedTargetID *)targetId; + @end /** @@ -93,7 +99,7 @@ NS_ASSUME_NONNULL_BEGIN * FSTRemoteStore handles all interaction with the backend through a simple, clean interface. This * class is not thread safe and should be only called from the worker dispatch queue. */ -@interface FSTRemoteStore : NSObject +@interface FSTRemoteStore : NSObject - (instancetype)initWithLocalStore:(FSTLocalStore *)localStore datastore:(FSTDatastore *)datastore diff --git a/Firestore/Source/Remote/FSTRemoteStore.mm b/Firestore/Source/Remote/FSTRemoteStore.mm index 7338ffb..54d00c4 100644 --- a/Firestore/Source/Remote/FSTRemoteStore.mm +++ b/Firestore/Source/Remote/FSTRemoteStore.mm @@ -96,14 +96,13 @@ static const int kMaxPendingWrites = 10; * 0 we know that the client and server are in the same state (once this state * is reached the targetId is removed from the map to free the memory). */ -@property(nonatomic, strong, readonly) - NSMutableDictionary *pendingTargetResponses; -@property(nonatomic, strong) NSMutableArray *accumulatedChanges; @property(nonatomic, assign) FSTBatchID lastBatchSeen; @property(nonatomic, strong, readonly) FSTOnlineStateTracker *onlineStateTracker; +@property(nonatomic, strong, nullable) FSTWatchChangeAggregator *watchChangeAggregator; + #pragma mark Write Stream // The writeStream is null when the network is disabled. The non-null check is performed by // isNetworkEnabled. @@ -126,8 +125,6 @@ static const int kMaxPendingWrites = 10; _localStore = localStore; _datastore = datastore; _listenTargets = [NSMutableDictionary dictionary]; - _pendingTargetResponses = [NSMutableDictionary dictionary]; - _accumulatedChanges = [NSMutableArray array]; _lastBatchSeen = kFSTBatchIDUnknown; _pendingWrites = [NSMutableArray array]; @@ -229,6 +226,7 @@ static const int kMaxPendingWrites = 10; - (void)startWatchStream { HARD_ASSERT([self shouldStartWatchStream], "startWatchStream: called when shouldStartWatchStream: is false."); + _watchChangeAggregator = [[FSTWatchChangeAggregator alloc] initWithTargetMetadataProvider:self]; [self.watchStream startWithDelegate:self]; [self.onlineStateTracker handleWatchStreamStart]; } @@ -248,7 +246,7 @@ static const int kMaxPendingWrites = 10; } - (void)sendWatchRequestWithQueryData:(FSTQueryData *)queryData { - [self recordPendingRequestForTargetID:@(queryData.targetID)]; + [self.watchChangeAggregator recordTargetRequest:@(queryData.targetID)]; [self.watchStream watchQuery:queryData]; } @@ -267,16 +265,10 @@ static const int kMaxPendingWrites = 10; } - (void)sendUnwatchRequestForTargetID:(FSTBoxedTargetID *)targetID { - [self recordPendingRequestForTargetID:targetID]; + [self.watchChangeAggregator recordTargetRequest:targetID]; [self.watchStream unwatchTargetID:[targetID intValue]]; } -- (void)recordPendingRequestForTargetID:(FSTBoxedTargetID *)targetID { - NSNumber *count = [self.pendingTargetResponses objectForKey:targetID]; - count = @([count intValue] + 1); - [self.pendingTargetResponses setObject:count forKey:targetID]; -} - /** * Returns YES if the network is enabled, the watch stream has not yet been started and there are * active watch targets. @@ -286,11 +278,7 @@ static const int kMaxPendingWrites = 10; } - (void)cleanUpWatchStreamState { - // If the connection is closed then we'll never get a snapshot version for the accumulated - // changes and so we'll never be able to complete the batch. When we start up again the server - // is going to resend these changes anyway, so just toss the accumulated state. - [self.accumulatedChanges removeAllObjects]; - [self.pendingTargetResponses removeAllObjects]; + _watchChangeAggregator = nil; } - (void)watchStreamDidOpen { @@ -305,28 +293,27 @@ static const int kMaxPendingWrites = 10; // Mark the connection as Online because we got a message from the server. [self.onlineStateTracker updateState:FSTOnlineStateOnline]; - FSTWatchTargetChange *watchTargetChange = - [change isKindOfClass:[FSTWatchTargetChange class]] ? (FSTWatchTargetChange *)change : nil; - - if (watchTargetChange && watchTargetChange.state == FSTWatchTargetChangeStateRemoved && - watchTargetChange.cause) { - // There was an error on a target, don't wait for a consistent snapshot to raise events - [self processTargetErrorForWatchChange:(FSTWatchTargetChange *)change]; - } else { - // Accumulate watch changes but don't process them if there's no snapshotVersion or it's - // older than a previous snapshot we've processed (can happen after we resume a target - // using a resume token). - [self.accumulatedChanges addObject:change]; - if (snapshotVersion == SnapshotVersion::None() || - snapshotVersion < [self.localStore lastRemoteSnapshotVersion]) { - return; + if ([change isKindOfClass:[FSTWatchTargetChange class]]) { + FSTWatchTargetChange *watchTargetChange = (FSTWatchTargetChange *)change; + if (watchTargetChange.state == FSTWatchTargetChangeStateRemoved && watchTargetChange.cause) { + // There was an error on a target, don't wait for a consistent snapshot to raise events + return [self processTargetErrorForWatchChange:watchTargetChange]; + } else { + [self.watchChangeAggregator handleTargetChange:watchTargetChange]; } + } else if ([change isKindOfClass:[FSTDocumentWatchChange class]]) { + [self.watchChangeAggregator handleDocumentChange:(FSTDocumentWatchChange *)change]; + } else { + HARD_ASSERT([change isKindOfClass:[FSTExistenceFilterWatchChange class]], + "Expected watchChange to be an instance of FSTExistenceFilterWatchChange"); + [self.watchChangeAggregator handleExistenceFilter:(FSTExistenceFilterWatchChange *)change]; + } - // Create a batch, giving it the accumulatedChanges array. - NSArray *changes = self.accumulatedChanges; - self.accumulatedChanges = [NSMutableArray array]; - - [self processBatchedWatchChanges:changes snapshotVersion:snapshotVersion]; + if (snapshotVersion != SnapshotVersion::None() && + snapshotVersion >= [self.localStore lastRemoteSnapshotVersion]) { + // We have received a target change with a global snapshot if the snapshot version is not equal + // to SnapshotVersion.None(). + [self raiseWatchSnapshotWithSnapshotVersion:snapshotVersion]; } } @@ -353,107 +340,60 @@ static const int kMaxPendingWrites = 10; * Takes a batch of changes from the Datastore, repackages them as a RemoteEvent, and passes that * on to the SyncEngine. */ -- (void)processBatchedWatchChanges:(NSArray *)changes - snapshotVersion:(const SnapshotVersion &)snapshotVersion { - FSTWatchChangeAggregator *aggregator = - [[FSTWatchChangeAggregator alloc] initWithSnapshotVersion:snapshotVersion - listenTargets:self.listenTargets - pendingTargetResponses:self.pendingTargetResponses]; - [aggregator addWatchChanges:changes]; - FSTRemoteEvent *remoteEvent = [aggregator remoteEvent]; - [self.pendingTargetResponses removeAllObjects]; - [self.pendingTargetResponses setDictionary:aggregator.pendingTargetResponses]; - - // Handle existence filters and existence filter mismatches - [aggregator.existenceFilters enumerateKeysAndObjectsUsingBlock:^(FSTBoxedTargetID *target, - FSTExistenceFilter *filter, - BOOL *stop) { - FSTTargetID targetID = target.intValue; - - FSTQueryData *queryData = self.listenTargets[target]; - FSTQuery *query = queryData.query; - if (!queryData) { - // A watched target might have been removed already. - return; - - } else if ([query isDocumentQuery]) { - if (filter.count == 0) { - // The existence filter told us the document does not exist. - // We need to deduce that this document does not exist and apply a deleted document to our - // updates. Without applying a deleted document there might be another query that will - // raise this document as part of a snapshot until it is resolved, essentially exposing - // inconsistency between queries - const DocumentKey key{query.path}; - FSTDeletedDocument *deletedDoc = - [FSTDeletedDocument documentWithKey:key version:snapshotVersion]; - [remoteEvent addDocumentUpdate:deletedDoc]; - } else { - HARD_ASSERT(filter.count == 1, "Single document existence filter with count: %s", - filter.count); - } - - } else { - // Not a document query. - DocumentKeySet trackedRemote = [self.localStore remoteDocumentKeysForTarget:targetID]; - FSTTargetMapping *mapping = remoteEvent.targetChanges[target].mapping; - if (mapping) { - if ([mapping isKindOfClass:[FSTUpdateMapping class]]) { - FSTUpdateMapping *update = (FSTUpdateMapping *)mapping; - trackedRemote = [update applyTo:trackedRemote]; - } else { - HARD_ASSERT([mapping isKindOfClass:[FSTResetMapping class]], - "Expected either reset or update mapping but got something else %s", mapping); - trackedRemote = ((FSTResetMapping *)mapping).documents; - } - } +- (void)raiseWatchSnapshotWithSnapshotVersion:(const SnapshotVersion &)snapshotVersion { + HARD_ASSERT(snapshotVersion != SnapshotVersion::None(), + "Can't raise event for unknown SnapshotVersion"); - if (trackedRemote.size() != static_cast(filter.count)) { - LOG_DEBUG("Existence filter mismatch, resetting mapping"); - - // Make sure the mismatch is exposed in the remote event - [remoteEvent handleExistenceFilterMismatchForTargetID:target]; - - // Clear the resume token for the query, since we're in a known mismatch state. - queryData = [[FSTQueryData alloc] initWithQuery:query - targetID:targetID - listenSequenceNumber:queryData.sequenceNumber - purpose:queryData.purpose]; - self.listenTargets[target] = queryData; - - // Cause a hard reset by unwatching and rewatching immediately, but deliberately don't - // send a resume token so that we get a full update. - [self sendUnwatchRequestForTargetID:@(targetID)]; - - // Mark the query we send as being on behalf of an existence filter mismatch, but don't - // actually retain that in listenTargets. This ensures that we flag the first re-listen - // this way without impacting future listens of this target (that might happen e.g. on - // reconnect). - FSTQueryData *requestQueryData = - [[FSTQueryData alloc] initWithQuery:query - targetID:targetID - listenSequenceNumber:queryData.sequenceNumber - purpose:FSTQueryPurposeExistenceFilterMismatch]; - [self sendWatchRequestWithQueryData:requestQueryData]; - } - } - }]; + FSTRemoteEvent *remoteEvent = + [self.watchChangeAggregator remoteEventAtSnapshotVersion:snapshotVersion]; // Update in-memory resume tokens. FSTLocalStore will update the persistent view of these when // applying the completed FSTRemoteEvent. - [remoteEvent.targetChanges enumerateKeysAndObjectsUsingBlock:^( - FSTBoxedTargetID *target, FSTTargetChange *change, BOOL *stop) { - NSData *resumeToken = change.resumeToken; + for (const auto &entry : remoteEvent.targetChanges) { + NSData *resumeToken = entry.second.resumeToken; if (resumeToken.length > 0) { - FSTQueryData *queryData = self->_listenTargets[target]; + FSTBoxedTargetID *targetID = @(entry.first); + FSTQueryData *queryData = _listenTargets[targetID]; // A watched target might have been removed already. if (queryData) { - self->_listenTargets[target] = - [queryData queryDataByReplacingSnapshotVersion:change.snapshotVersion + _listenTargets[targetID] = + [queryData queryDataByReplacingSnapshotVersion:snapshotVersion resumeToken:resumeToken sequenceNumber:queryData.sequenceNumber]; } } - }]; + } + + // Re-establish listens for the targets that have been invalidated by existence filter mismatches. + for (FSTTargetID targetID : remoteEvent.targetMismatches) { + FSTQueryData *queryData = self.listenTargets[@(targetID)]; + + if (!queryData) { + // A watched target might have been removed already. + continue; + } + + // Clear the resume token for the query, since we're in a known mismatch state. + queryData = [[FSTQueryData alloc] initWithQuery:queryData.query + targetID:targetID + listenSequenceNumber:queryData.sequenceNumber + purpose:queryData.purpose]; + self.listenTargets[@(targetID)] = queryData; + + // Cause a hard reset by unwatching and rewatching immediately, but deliberately don't send a + // resume token so that we get a full update. + [self sendUnwatchRequestForTargetID:@(targetID)]; + + // Mark the query we send as being on behalf of an existence filter mismatch, but don't actually + // retain that in listenTargets. This ensures that we flag the first re-listen this way without + // impacting future listens of this target (that might happen e.g. on reconnect). + FSTQueryData *requestQueryData = + [[FSTQueryData alloc] initWithQuery:queryData.query + targetID:targetID + listenSequenceNumber:queryData.sequenceNumber + purpose:FSTQueryPurposeExistenceFilterMismatch]; + [self sendWatchRequestWithQueryData:requestQueryData]; + } // Finally handle remote event [self.syncEngine applyRemoteEvent:remoteEvent]; @@ -471,6 +411,14 @@ static const int kMaxPendingWrites = 10; } } +- (firebase::firestore::model::DocumentKeySet)remoteKeysForTarget:(FSTBoxedTargetID *)targetID { + return [self.syncEngine remoteKeysForTarget:targetID]; +} + +- (nullable FSTQueryData *)queryDataForTarget:(FSTBoxedTargetID *)targetID { + return self.listenTargets[targetID]; +} + #pragma mark Write Stream /** diff --git a/Firestore/Source/Remote/FSTWatchChange.mm b/Firestore/Source/Remote/FSTWatchChange.mm index 284e980..04656b9 100644 --- a/Firestore/Source/Remote/FSTWatchChange.mm +++ b/Firestore/Source/Remote/FSTWatchChange.mm @@ -127,7 +127,7 @@ NS_ASSUME_NONNULL_BEGIN if (self) { _state = state; _targetIDs = targetIDs; - _resumeToken = resumeToken; + _resumeToken = [resumeToken copy]; _cause = cause; } return self; -- cgit v1.2.3