|
14 | 14 |
|
15 | 15 | package com.google.firebase.firestore;
|
16 | 16 |
|
| 17 | +import static com.google.common.truth.Truth.assertWithMessage; |
17 | 18 | import static com.google.firebase.firestore.testutil.IntegrationTestUtil.isRunningAgainstEmulator;
|
18 | 19 | import static com.google.firebase.firestore.testutil.IntegrationTestUtil.nullList;
|
19 | 20 | import static com.google.firebase.firestore.testutil.IntegrationTestUtil.querySnapshotToIds;
|
|
32 | 33 | import static org.junit.Assert.assertTrue;
|
33 | 34 | import static org.junit.Assume.assumeFalse;
|
34 | 35 |
|
| 36 | +import android.os.SystemClock; |
| 37 | + |
35 | 38 | import androidx.test.ext.junit.runners.AndroidJUnit4;
|
36 | 39 | import com.google.android.gms.tasks.Task;
|
37 | 40 | import com.google.common.collect.Lists;
|
38 | 41 | import com.google.firebase.firestore.Query.Direction;
|
| 42 | +import com.google.firebase.firestore.remote.WatchChangeAggregatorTestingHooksAccessor; |
39 | 43 | import com.google.firebase.firestore.testutil.EventAccumulator;
|
40 | 44 | import com.google.firebase.firestore.testutil.IntegrationTestUtil;
|
41 | 45 | import java.util.ArrayList;
|
42 | 46 | import java.util.HashMap;
|
| 47 | +import java.util.HashSet; |
43 | 48 | import java.util.LinkedHashMap;
|
44 | 49 | import java.util.List;
|
45 | 50 | import java.util.Map;
|
@@ -1033,43 +1038,163 @@ public void testMultipleUpdatesWhileOffline() {
|
1033 | 1038 | }
|
1034 | 1039 |
|
1035 | 1040 | @Test
|
1036 |
| - public void resumingQueryShouldRemoveDeletedDocumentsIndicatedByExistenceFilter() |
1037 |
| - throws InterruptedException { |
1038 |
| - assumeFalse( |
1039 |
| - "Skip this test when running against the Firestore emulator as there is a bug related to " |
1040 |
| - + "sending existence filter in response: b/270731363.", |
1041 |
| - isRunningAgainstEmulator()); |
1042 |
| - |
| 1041 | + public void resumingAQueryShouldUseBloomFilterToAvoidFullRequery() throws Exception { |
| 1042 | + // Prepare the names and contents of the 100 documents to create. |
1043 | 1043 | Map<String, Map<String, Object>> testData = new HashMap<>();
|
1044 |
| - for (int i = 1; i <= 100; i++) { |
1045 |
| - testData.put("doc" + i, map("key", i)); |
| 1044 | + for (int i = 0; i < 100; i++) { |
| 1045 | + testData.put("doc" + (1000 + i), map("key", 42)); |
1046 | 1046 | }
|
1047 |
| - CollectionReference collection = testCollectionWithDocs(testData); |
1048 | 1047 |
|
1049 |
| - // Populate the cache and save the resume token. |
1050 |
| - QuerySnapshot snapshot1 = waitFor(collection.get()); |
1051 |
| - assertEquals(snapshot1.size(), 100); |
1052 |
| - List<DocumentSnapshot> documents = snapshot1.getDocuments(); |
| 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 | + while (true) { |
| 1051 | + // Create 100 documents in a new collection. |
| 1052 | + CollectionReference collection = testCollectionWithDocs(testData); |
| 1053 | + |
| 1054 | + // Run a query to populate the local cache with the 100 documents and a resume token. |
| 1055 | + List<DocumentReference> createdDocuments = new ArrayList<>(); |
| 1056 | + { |
| 1057 | + QuerySnapshot querySnapshot = waitFor(collection.get()); |
| 1058 | + assertWithMessage("querySnapshot1").that(querySnapshot.size()).isEqualTo(100); |
| 1059 | + for (DocumentSnapshot documentSnapshot : querySnapshot.getDocuments()) { |
| 1060 | + createdDocuments.add(documentSnapshot.getReference()); |
| 1061 | + } |
| 1062 | + } |
| 1063 | + |
| 1064 | + // Delete 50 of the 100 documents. Do this in a transaction, rather than |
| 1065 | + // DocumentReference.delete(), to avoid affecting the local cache. |
| 1066 | + HashSet<String> deletedDocumentIds = new HashSet<>(); |
| 1067 | + waitFor( |
| 1068 | + collection |
| 1069 | + .getFirestore() |
| 1070 | + .runTransaction( |
| 1071 | + transaction -> { |
| 1072 | + for (int i = 0; i < createdDocuments.size(); i+=2) { |
| 1073 | + DocumentReference documentToDelete = createdDocuments.get(i); |
| 1074 | + transaction.delete(documentToDelete); |
| 1075 | + deletedDocumentIds.add(documentToDelete.getId()); |
| 1076 | + } |
| 1077 | + return null; |
| 1078 | + })); |
| 1079 | + |
| 1080 | + // Wait for 10 seconds, during which Watch will stop tracking the query and will send an |
| 1081 | + // existence filter rather than "delete" events when the query is resumed. |
| 1082 | + Thread.sleep(10000); |
| 1083 | + |
| 1084 | + // Resume the query and save the resulting snapshot for verification. Use some internal |
| 1085 | + // testing hooks to "capture" the existence filter mismatches to verify that Watch sent a |
| 1086 | + // bloom filter, and it was used to avert a full requery. |
| 1087 | + QuerySnapshot snapshot2; |
| 1088 | + WatchChangeAggregatorTestingHooksAccessor.ExistenceFilterMismatchInfo existenceFilterMismatchInfo; |
| 1089 | + ExistenceFilterMismatchAccumulator existenceFilterMismatchAccumulator = new ExistenceFilterMismatchAccumulator(); |
| 1090 | + existenceFilterMismatchAccumulator.register(); |
| 1091 | + try { |
| 1092 | + snapshot2 = waitFor(collection.get()); |
| 1093 | + existenceFilterMismatchInfo = existenceFilterMismatchAccumulator.waitForExistenceFilterMismatch(/*timeoutMillis=*/5000); |
| 1094 | + } finally { |
| 1095 | + existenceFilterMismatchAccumulator.unregister(); |
| 1096 | + } |
| 1097 | + |
| 1098 | + // Verify that the snapshot from the resumed query contains the expected documents; that is, |
| 1099 | + // that it contains the 50 documents that were _not_ deleted. |
| 1100 | + // TODO(b/270731363): Remove the "if" condition below once the Firestore Emulator is fixed to |
| 1101 | + // send an existence filter. At the time of writing, the Firestore emulator fails to send an |
| 1102 | + // existence filter, resulting in the client including the deleted documents in the snapshot |
| 1103 | + // of the resumed query. |
| 1104 | + if (!(isRunningAgainstEmulator() && snapshot2.size() == 100)) { |
| 1105 | + HashSet<String> actualDocumentIds = new HashSet<>(); |
| 1106 | + for (DocumentSnapshot documentSnapshot : snapshot2.getDocuments()) { |
| 1107 | + actualDocumentIds.add(documentSnapshot.getId()); |
| 1108 | + } |
| 1109 | + HashSet<String> expectedDocumentIds = new HashSet<>(); |
| 1110 | + for (DocumentReference documentRef : createdDocuments) { |
| 1111 | + if (!deletedDocumentIds.contains(documentRef.getId())) { |
| 1112 | + expectedDocumentIds.add(documentRef.getId()); |
| 1113 | + } |
| 1114 | + } |
| 1115 | + assertWithMessage("snapshot2.docs").that(actualDocumentIds).containsExactlyElementsIn(expectedDocumentIds); |
| 1116 | + } |
| 1117 | + |
| 1118 | + // Skip the verification of the existence filter mismatch when testing against the Firestore |
| 1119 | + // emulator because the Firestore emulator does not include the `unchanged_names` bloom filter |
| 1120 | + // when it sends ExistenceFilter messages. Some day the emulator _may_ implement this logic, |
| 1121 | + // at which time this short-circuit can be removed. |
| 1122 | + if (isRunningAgainstEmulator()) { |
| 1123 | + return; |
| 1124 | + } |
| 1125 | + |
| 1126 | + // Verify that Watch sent an existence filter with the correct counts when the query was |
| 1127 | + // resumed. |
| 1128 | + assertWithMessage("localCacheCount").that(existenceFilterMismatchInfo.localCacheCount()).isEqualTo(100); |
| 1129 | + assertWithMessage("existenceFilterCount").that(existenceFilterMismatchInfo.existenceFilterCount()).isEqualTo(50); |
| 1130 | + } |
| 1131 | + } |
| 1132 | + |
| 1133 | + private static final class ExistenceFilterMismatchAccumulator { |
| 1134 | + |
| 1135 | + private final ExistenceFilterMismatchListenerImpl listener = new ExistenceFilterMismatchListenerImpl(); |
| 1136 | + private ListenerRegistration listenerRegistration = null; |
| 1137 | + |
| 1138 | + void register() { |
| 1139 | + if (listenerRegistration != null) { |
| 1140 | + throw new IllegalStateException("already registered"); |
| 1141 | + } |
| 1142 | + listenerRegistration = WatchChangeAggregatorTestingHooksAccessor.addExistenceFilterMismatchListener(listener); |
| 1143 | + } |
| 1144 | + |
| 1145 | + void unregister() { |
| 1146 | + if (listenerRegistration == null) { |
| 1147 | + return; |
| 1148 | + } |
| 1149 | + listenerRegistration.remove(); |
| 1150 | + listenerRegistration = null; |
| 1151 | + } |
| 1152 | + |
| 1153 | + WatchChangeAggregatorTestingHooksAccessor.ExistenceFilterMismatchInfo waitForExistenceFilterMismatch(long timeoutMillis) throws InterruptedException { |
| 1154 | + if (listenerRegistration == null) { |
| 1155 | + throw new IllegalStateException("must be registered before waiting for an existence filter mismatch"); |
| 1156 | + } |
| 1157 | + return listener.waitForExistenceFilterMismatch(timeoutMillis); |
| 1158 | + } |
| 1159 | + |
| 1160 | + private final class ExistenceFilterMismatchListenerImpl implements WatchChangeAggregatorTestingHooksAccessor.ExistenceFilterMismatchListener { |
| 1161 | + |
| 1162 | + private final ArrayList<WatchChangeAggregatorTestingHooksAccessor.ExistenceFilterMismatchInfo> existenceFilterMismatches = new ArrayList<>(); |
| 1163 | + |
| 1164 | + @Override |
| 1165 | + public void onExistenceFilterMismatch(WatchChangeAggregatorTestingHooksAccessor.ExistenceFilterMismatchInfo info) { |
| 1166 | + synchronized (existenceFilterMismatchesLock) { |
| 1167 | + existenceFilterMismatches.add(info); |
| 1168 | + existenceFilterMismatchesLock.notifyAll(); |
| 1169 | + } |
| 1170 | + } |
| 1171 | + |
| 1172 | + WatchChangeAggregatorTestingHooksAccessor.ExistenceFilterMismatchInfo waitForExistenceFilterMismatch(long timeoutMillis) throws InterruptedException { |
| 1173 | + if (timeoutMillis <= 0) { |
| 1174 | + throw new IllegalArgumentException("invalid timeout: " + timeoutMillis); |
| 1175 | + } |
| 1176 | + synchronized (existenceFilterMismatchesLock) { |
| 1177 | + long endTimeMillis = SystemClock.uptimeMillis() + timeoutMillis; |
| 1178 | + while (true) { |
| 1179 | + if (existenceFilterMismatches.size() > 0) { |
| 1180 | + return existenceFilterMismatches.remove(0); |
| 1181 | + } |
| 1182 | + long currentWaitMillis = endTimeMillis - SystemClock.uptimeMillis(); |
| 1183 | + if (currentWaitMillis <= 0) { |
| 1184 | + throw new WaitForExistenceFilterMismatchTimeoutException("timeout (" + timeoutMillis + "ms) waiting for an existence filter mismatch"); |
| 1185 | + } |
| 1186 | + existenceFilterMismatchesLock.wait(currentWaitMillis); |
| 1187 | + } |
| 1188 | + } |
| 1189 | + } |
| 1190 | + |
| 1191 | + final class WaitForExistenceFilterMismatchTimeoutException extends RuntimeException { |
| 1192 | + WaitForExistenceFilterMismatchTimeoutException(String message) { |
| 1193 | + super(message); |
| 1194 | + } |
| 1195 | + } |
| 1196 | + } |
1053 | 1197 |
|
1054 |
| - // Delete 50 docs in transaction so that it doesn't affect local cache. |
1055 |
| - waitFor( |
1056 |
| - collection |
1057 |
| - .getFirestore() |
1058 |
| - .runTransaction( |
1059 |
| - transaction -> { |
1060 |
| - for (int i = 1; i <= 50; i++) { |
1061 |
| - DocumentReference docRef = documents.get(i).getReference(); |
1062 |
| - transaction.delete(docRef); |
1063 |
| - } |
1064 |
| - return null; |
1065 |
| - })); |
1066 |
| - |
1067 |
| - // Wait 10 seconds, during which Watch will stop tracking the query |
1068 |
| - // and will send an existence filter rather than "delete" events. |
1069 |
| - Thread.sleep(10000); |
1070 |
| - |
1071 |
| - QuerySnapshot snapshot2 = waitFor(collection.get()); |
1072 |
| - assertEquals(snapshot2.size(), 50); |
1073 | 1198 | }
|
1074 | 1199 |
|
1075 | 1200 | // TODO(orquery): Enable this test when prod supports OR queries.
|
|
0 commit comments