Skip to content

Commit 4a18354

Browse files
authored
Merge d9a59e2 into 41890a0
2 parents 41890a0 + d9a59e2 commit 4a18354

File tree

54 files changed

+1420
-158
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

54 files changed

+1420
-158
lines changed

firebase-firestore/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Unreleased
2-
2+
- [feature] Implemented an optimization in the local cache synchronization logic that reduces the number of billed document reads when documents were deleted on the server while the client was not actively listening to the query (e.g. while the client was offline). (GitHub [#4982](//github.com/firebase/firebase-android-sdk/pull/4982){: .external})
33

44
# 24.6.0
55
* [fixed] Fixed stack overflow caused by deeply nested server timestamps.

firebase-firestore/src/androidTest/java/com/google/firebase/firestore/QueryTest.java

Lines changed: 118 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
import com.google.android.gms.tasks.Task;
3939
import com.google.common.collect.Lists;
4040
import com.google.firebase.firestore.Query.Direction;
41+
import com.google.firebase.firestore.remote.TestingHooksUtil.ExistenceFilterBloomFilterInfo;
4142
import com.google.firebase.firestore.remote.TestingHooksUtil.ExistenceFilterMismatchInfo;
4243
import com.google.firebase.firestore.testutil.EventAccumulator;
4344
import com.google.firebase.firestore.testutil.IntegrationTestUtil;
@@ -1037,101 +1038,135 @@ public void testMultipleUpdatesWhileOffline() {
10371038
}
10381039

10391040
@Test
1040-
public void resumingAQueryShouldUseExistenceFilterToDetectDeletes() throws Exception {
1041+
public void resumingAQueryShouldUseBloomFilterToAvoidFullRequery() throws Exception {
10411042
// Prepare the names and contents of the 100 documents to create.
10421043
Map<String, Map<String, Object>> testData = new HashMap<>();
10431044
for (int i = 0; i < 100; i++) {
10441045
testData.put("doc" + (1000 + i), map("key", 42));
10451046
}
10461047

1047-
// Create 100 documents in a new collection.
1048-
CollectionReference collection = testCollectionWithDocs(testData);
1049-
1050-
// Run a query to populate the local cache with the 100 documents and a resume token.
1051-
List<DocumentReference> createdDocuments = new ArrayList<>();
1052-
{
1053-
QuerySnapshot querySnapshot = waitFor(collection.get());
1054-
assertWithMessage("querySnapshot1").that(querySnapshot.size()).isEqualTo(100);
1055-
for (DocumentSnapshot documentSnapshot : querySnapshot.getDocuments()) {
1056-
createdDocuments.add(documentSnapshot.getReference());
1048+
// Each iteration of the "while" loop below runs a single iteration of the test. The test will
1049+
// be run multiple times only if a bloom filter false positive occurs.
1050+
int attemptNumber = 0;
1051+
while (true) {
1052+
attemptNumber++;
1053+
1054+
// Create 100 documents in a new collection.
1055+
CollectionReference collection = testCollectionWithDocs(testData);
1056+
1057+
// Run a query to populate the local cache with the 100 documents and a resume token.
1058+
List<DocumentReference> createdDocuments = new ArrayList<>();
1059+
{
1060+
QuerySnapshot querySnapshot = waitFor(collection.get());
1061+
assertWithMessage("querySnapshot1").that(querySnapshot.size()).isEqualTo(100);
1062+
for (DocumentSnapshot documentSnapshot : querySnapshot.getDocuments()) {
1063+
createdDocuments.add(documentSnapshot.getReference());
1064+
}
1065+
}
1066+
assertWithMessage("createdDocuments").that(createdDocuments).hasSize(100);
1067+
1068+
// Delete 50 of the 100 documents. Do this in a transaction, rather than
1069+
// DocumentReference.delete(), to avoid affecting the local cache.
1070+
HashSet<String> deletedDocumentIds = new HashSet<>();
1071+
waitFor(
1072+
collection
1073+
.getFirestore()
1074+
.runTransaction(
1075+
transaction -> {
1076+
for (int i = 0; i < createdDocuments.size(); i += 2) {
1077+
DocumentReference documentToDelete = createdDocuments.get(i);
1078+
transaction.delete(documentToDelete);
1079+
deletedDocumentIds.add(documentToDelete.getId());
1080+
}
1081+
return null;
1082+
}));
1083+
assertWithMessage("deletedDocumentIds").that(deletedDocumentIds).hasSize(50);
1084+
1085+
// Wait for 10 seconds, during which Watch will stop tracking the query and will send an
1086+
// existence filter rather than "delete" events when the query is resumed.
1087+
Thread.sleep(10000);
1088+
1089+
// Resume the query and save the resulting snapshot for verification. Use some internal
1090+
// testing hooks to "capture" the existence filter mismatches to verify that Watch sent a
1091+
// bloom filter, and it was used to avert a full requery.
1092+
AtomicReference<QuerySnapshot> snapshot2Ref = new AtomicReference<>();
1093+
ArrayList<ExistenceFilterMismatchInfo> existenceFilterMismatches =
1094+
captureExistenceFilterMismatches(
1095+
() -> {
1096+
QuerySnapshot querySnapshot = waitFor(collection.get());
1097+
snapshot2Ref.set(querySnapshot);
1098+
});
1099+
QuerySnapshot snapshot2 = snapshot2Ref.get();
1100+
1101+
// Verify that the snapshot from the resumed query contains the expected documents; that is,
1102+
// that it contains the 50 documents that were _not_ deleted.
1103+
// TODO(b/270731363): Remove the "if" condition below once the Firestore Emulator is fixed to
1104+
// send an existence filter. At the time of writing, the Firestore emulator fails to send an
1105+
// existence filter, resulting in the client including the deleted documents in the snapshot
1106+
// of the resumed query.
1107+
if (!(isRunningAgainstEmulator() && snapshot2.size() == 100)) {
1108+
HashSet<String> actualDocumentIds = new HashSet<>();
1109+
for (DocumentSnapshot documentSnapshot : snapshot2.getDocuments()) {
1110+
actualDocumentIds.add(documentSnapshot.getId());
1111+
}
1112+
HashSet<String> expectedDocumentIds = new HashSet<>();
1113+
for (DocumentReference documentRef : createdDocuments) {
1114+
if (!deletedDocumentIds.contains(documentRef.getId())) {
1115+
expectedDocumentIds.add(documentRef.getId());
1116+
}
1117+
}
1118+
assertWithMessage("snapshot2.docs")
1119+
.that(actualDocumentIds)
1120+
.containsExactlyElementsIn(expectedDocumentIds);
10571121
}
1058-
}
1059-
assertWithMessage("createdDocuments").that(createdDocuments).hasSize(100);
10601122

1061-
// Delete 50 of the 100 documents. Do this in a transaction, rather than
1062-
// DocumentReference.delete(), to avoid affecting the local cache.
1063-
HashSet<String> deletedDocumentIds = new HashSet<>();
1064-
waitFor(
1065-
collection
1066-
.getFirestore()
1067-
.runTransaction(
1068-
transaction -> {
1069-
for (int i = 0; i < createdDocuments.size(); i += 2) {
1070-
DocumentReference documentToDelete = createdDocuments.get(i);
1071-
transaction.delete(documentToDelete);
1072-
deletedDocumentIds.add(documentToDelete.getId());
1073-
}
1074-
return null;
1075-
}));
1076-
assertWithMessage("deletedDocumentIds").that(deletedDocumentIds).hasSize(50);
1077-
1078-
// Wait for 10 seconds, during which Watch will stop tracking the query and will send an
1079-
// existence filter rather than "delete" events when the query is resumed.
1080-
Thread.sleep(10000);
1081-
1082-
// Resume the query and save the resulting snapshot for verification. Use some internal testing
1083-
// hooks to "capture" the existence filter mismatches to verify them.
1084-
AtomicReference<QuerySnapshot> snapshot2Ref = new AtomicReference<>();
1085-
ArrayList<ExistenceFilterMismatchInfo> existenceFilterMismatches =
1086-
captureExistenceFilterMismatches(
1087-
() -> {
1088-
QuerySnapshot querySnapshot = waitFor(collection.get());
1089-
snapshot2Ref.set(querySnapshot);
1090-
});
1091-
QuerySnapshot snapshot2 = snapshot2Ref.get();
1092-
1093-
// Verify that the snapshot from the resumed query contains the expected documents; that is,
1094-
// that it contains the 50 documents that were _not_ deleted.
1095-
// TODO(b/270731363): Remove the "if" condition below once the Firestore Emulator is fixed to
1096-
// send an existence filter. At the time of writing, the Firestore emulator fails to send an
1097-
// existence filter, resulting in the client including the deleted documents in the snapshot
1098-
// of the resumed query.
1099-
if (!(isRunningAgainstEmulator() && snapshot2.size() == 100)) {
1100-
HashSet<String> actualDocumentIds = new HashSet<>();
1101-
for (DocumentSnapshot documentSnapshot : snapshot2.getDocuments()) {
1102-
actualDocumentIds.add(documentSnapshot.getId());
1123+
// Skip the verification of the existence filter mismatch when testing against the Firestore
1124+
// emulator because the Firestore emulator does not include the `unchanged_names` bloom filter
1125+
// when it sends ExistenceFilter messages. Some day the emulator _may_ implement this logic,
1126+
// at which time this short-circuit can be removed.
1127+
if (isRunningAgainstEmulator()) {
1128+
return;
11031129
}
1104-
HashSet<String> expectedDocumentIds = new HashSet<>();
1105-
for (DocumentReference documentRef : createdDocuments) {
1106-
if (!deletedDocumentIds.contains(documentRef.getId())) {
1107-
expectedDocumentIds.add(documentRef.getId());
1108-
}
1130+
1131+
// Verify that Watch sent an existence filter with the correct counts when the query was
1132+
// resumed.
1133+
assertWithMessage("Watch should have sent exactly 1 existence filter")
1134+
.that(existenceFilterMismatches)
1135+
.hasSize(1);
1136+
ExistenceFilterMismatchInfo existenceFilterMismatchInfo = existenceFilterMismatches.get(0);
1137+
assertWithMessage("localCacheCount")
1138+
.that(existenceFilterMismatchInfo.localCacheCount())
1139+
.isEqualTo(100);
1140+
assertWithMessage("existenceFilterCount")
1141+
.that(existenceFilterMismatchInfo.existenceFilterCount())
1142+
.isEqualTo(50);
1143+
1144+
// Verify that Watch sent a valid bloom filter.
1145+
ExistenceFilterBloomFilterInfo bloomFilter = existenceFilterMismatchInfo.bloomFilter();
1146+
assertWithMessage("The bloom filter specified in the existence filter")
1147+
.that(bloomFilter)
1148+
.isNotNull();
1149+
assertWithMessage("hashCount").that(bloomFilter.hashCount()).isGreaterThan(0);
1150+
assertWithMessage("bitmapLength").that(bloomFilter.bitmapLength()).isGreaterThan(0);
1151+
assertWithMessage("padding").that(bloomFilter.padding()).isGreaterThan(0);
1152+
assertWithMessage("padding").that(bloomFilter.padding()).isLessThan(8);
1153+
1154+
// Verify that the bloom filter was successfully used to avert a full requery. If a false
1155+
// positive occurred then retry the entire test. Although statistically rare, false positives
1156+
// are expected to happen occasionally. When a false positive _does_ happen, just retry the
1157+
// test with a different set of documents. If that retry _also_ experiences a false positive,
1158+
// then fail the test because that is so improbable that something must have gone wrong.
1159+
if (attemptNumber == 1 && !bloomFilter.applied()) {
1160+
continue;
11091161
}
1110-
assertWithMessage("snapshot2.docs")
1111-
.that(actualDocumentIds)
1112-
.containsExactlyElementsIn(expectedDocumentIds);
1113-
}
11141162

1115-
// Skip the verification of the existence filter mismatch when testing against the Firestore
1116-
// emulator because the Firestore emulator fails to to send an existence filter at all.
1117-
// TODO(b/270731363): Enable the verification of the existence filter mismatch once the
1118-
// Firestore emulator is fixed to send an existence filter.
1119-
if (isRunningAgainstEmulator()) {
1120-
return;
1121-
}
1163+
assertWithMessage("bloom filter successfully applied with attemptNumber=" + attemptNumber)
1164+
.that(bloomFilter.applied())
1165+
.isTrue();
11221166

1123-
// Verify that Watch sent an existence filter with the correct counts when the query was
1124-
// resumed.
1125-
assertWithMessage("Watch should have sent exactly 1 existence filter")
1126-
.that(existenceFilterMismatches)
1127-
.hasSize(1);
1128-
ExistenceFilterMismatchInfo existenceFilterMismatchInfo = existenceFilterMismatches.get(0);
1129-
assertWithMessage("localCacheCount")
1130-
.that(existenceFilterMismatchInfo.localCacheCount())
1131-
.isEqualTo(100);
1132-
assertWithMessage("existenceFilterCount")
1133-
.that(existenceFilterMismatchInfo.existenceFilterCount())
1134-
.isEqualTo(50);
1167+
// Break out of the test loop now that the test passes.
1168+
break;
1169+
}
11351170
}
11361171

11371172
@Test

firebase-firestore/src/androidTest/java/com/google/firebase/firestore/remote/TestingHooksUtil.java

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
package com.google.firebase.firestore.remote;
1616

1717
import androidx.annotation.NonNull;
18+
import androidx.annotation.Nullable;
1819
import com.google.firebase.firestore.ListenerRegistration;
1920
import java.util.ArrayList;
2021

@@ -79,5 +80,37 @@ public int localCacheCount() {
7980
public int existenceFilterCount() {
8081
return info.existenceFilterCount();
8182
}
83+
84+
@Nullable
85+
public ExistenceFilterBloomFilterInfo bloomFilter() {
86+
TestingHooks.ExistenceFilterBloomFilterInfo bloomFilterInfo = info.bloomFilter();
87+
return bloomFilterInfo == null ? null : new ExistenceFilterBloomFilterInfo(bloomFilterInfo);
88+
}
89+
}
90+
91+
/** @see TestingHooks.ExistenceFilterBloomFilterInfo */
92+
public static final class ExistenceFilterBloomFilterInfo {
93+
94+
private final TestingHooks.ExistenceFilterBloomFilterInfo info;
95+
96+
ExistenceFilterBloomFilterInfo(@NonNull TestingHooks.ExistenceFilterBloomFilterInfo info) {
97+
this.info = info;
98+
}
99+
100+
public boolean applied() {
101+
return info.applied();
102+
}
103+
104+
public int hashCount() {
105+
return info.hashCount();
106+
}
107+
108+
public int bitmapLength() {
109+
return info.bitmapLength();
110+
}
111+
112+
public int padding() {
113+
return info.padding();
114+
}
82115
}
83116
}

firebase-firestore/src/main/java/com/google/firebase/firestore/core/SyncEngine.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,13 +205,14 @@ public int listen(Query query) {
205205
hardAssert(!queryViewsByQuery.containsKey(query), "We already listen to query: %s", query);
206206

207207
TargetData targetData = localStore.allocateTarget(query.toTarget());
208-
remoteStore.listen(targetData);
209208

210209
ViewSnapshot viewSnapshot =
211210
initializeViewAndComputeSnapshot(
212211
query, targetData.getTargetId(), targetData.getResumeToken());
213212
syncEngineListener.onViewSnapshots(Collections.singletonList(viewSnapshot));
214213

214+
remoteStore.listen(targetData);
215+
215216
return targetData.getTargetId();
216217
}
217218

@@ -430,7 +431,7 @@ public void handleRejectedListen(int targetId, Status error) {
430431
new RemoteEvent(
431432
SnapshotVersion.NONE,
432433
/* targetChanges= */ Collections.emptyMap(),
433-
/* targetMismatches= */ Collections.emptySet(),
434+
/* targetMismatches= */ Collections.emptyMap(),
434435
documentUpdates,
435436
limboDocuments);
436437
handleRemoteEvent(event);

firebase-firestore/src/main/java/com/google/firebase/firestore/local/LocalSerializer.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,8 @@ TargetData decodeTargetData(com.google.firebase.firestore.proto.Target targetPro
260260
QueryPurpose.LISTEN,
261261
version,
262262
lastLimboFreeSnapshotVersion,
263-
resumeToken);
263+
resumeToken,
264+
null);
264265
}
265266

266267
public com.google.firestore.bundle.BundledQuery encodeBundledQuery(BundledQuery bundledQuery) {

firebase-firestore/src/main/java/com/google/firebase/firestore/local/LocalStore.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -425,7 +425,7 @@ public ImmutableSortedMap<DocumentKey, Document> applyRemoteEvent(RemoteEvent re
425425
targetCache.addMatchingKeys(change.getAddedDocuments(), targetId);
426426

427427
TargetData newTargetData = oldTargetData.withSequenceNumber(sequenceNumber);
428-
if (remoteEvent.getTargetMismatches().contains(targetId)) {
428+
if (remoteEvent.getTargetMismatches().containsKey(targetId)) {
429429
newTargetData =
430430
newTargetData
431431
.withResumeToken(ByteString.EMPTY, SnapshotVersion.NONE)

firebase-firestore/src/main/java/com/google/firebase/firestore/local/QueryPurpose.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ public enum QueryPurpose {
2222
/** The query was used to refill a query after an existence filter mismatch. */
2323
EXISTENCE_FILTER_MISMATCH,
2424

25+
/**
26+
* The query target was used if the query is the result of a false positive in the bloom filter.
27+
*/
28+
EXISTENCE_FILTER_MISMATCH_BLOOM,
29+
2530
/** The query was used to resolve a limbo document. */
2631
LIMBO_RESOLUTION,
2732
}

0 commit comments

Comments
 (0)