Skip to content

Commit 7b34780

Browse files
authored
Merge branch 'master' into firebase-kotlin-format
2 parents f7f9c75 + 47e7a88 commit 7b34780

File tree

5 files changed

+178
-1
lines changed

5 files changed

+178
-1
lines changed

firebase-firestore/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Unreleased
22
* [feature] Add support for disjunctions in queries (`OR` queries).
3+
* [fixed] Fixed stack overflow caused by deeply nested server timestamps (#4702).
34

45
# 24.4.4
56
* [changed] Relaxed certain query validations performed by the SDK (#4231).

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

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
package com.google.firebase.firestore;
1616

17+
import static com.google.common.truth.Truth.assertWithMessage;
1718
import static com.google.firebase.firestore.testutil.IntegrationTestUtil.isRunningAgainstEmulator;
1819
import static com.google.firebase.firestore.testutil.IntegrationTestUtil.nullList;
1920
import static com.google.firebase.firestore.testutil.IntegrationTestUtil.querySnapshotToIds;
@@ -39,6 +40,8 @@
3940
import com.google.firebase.firestore.testutil.EventAccumulator;
4041
import com.google.firebase.firestore.testutil.IntegrationTestUtil;
4142
import java.util.ArrayList;
43+
import java.util.HashMap;
44+
import java.util.HashSet;
4245
import java.util.LinkedHashMap;
4346
import java.util.List;
4447
import java.util.Map;
@@ -1030,6 +1033,73 @@ public void testMultipleUpdatesWhileOffline() {
10301033
assertEquals(asList(map("foo", "zzyzx", "bar", "2")), querySnapshotToValues(snapshot2));
10311034
}
10321035

1036+
@Test
1037+
public void resumingAQueryShouldUseExistenceFilterToDetectDeletes() throws Exception {
1038+
// Prepare the names and contents of the 100 documents to create.
1039+
Map<String, Map<String, Object>> testData = new HashMap<>();
1040+
for (int i = 0; i < 100; i++) {
1041+
testData.put("doc" + (1000 + i), map("key", 42));
1042+
}
1043+
1044+
// Create 100 documents in a new collection.
1045+
CollectionReference collection = testCollectionWithDocs(testData);
1046+
1047+
// Run a query to populate the local cache with the 100 documents and a resume token.
1048+
List<DocumentReference> createdDocuments = new ArrayList<>();
1049+
{
1050+
QuerySnapshot querySnapshot = waitFor(collection.get());
1051+
assertWithMessage("querySnapshot1").that(querySnapshot.size()).isEqualTo(100);
1052+
for (DocumentSnapshot documentSnapshot : querySnapshot.getDocuments()) {
1053+
createdDocuments.add(documentSnapshot.getReference());
1054+
}
1055+
}
1056+
1057+
// Delete 50 of the 100 documents. Do this in a transaction, rather than
1058+
// DocumentReference.delete(), to avoid affecting the local cache.
1059+
HashSet<String> deletedDocumentIds = new HashSet<>();
1060+
waitFor(
1061+
collection
1062+
.getFirestore()
1063+
.runTransaction(
1064+
transaction -> {
1065+
for (int i = 0; i < createdDocuments.size(); i += 2) {
1066+
DocumentReference documentToDelete = createdDocuments.get(i);
1067+
transaction.delete(documentToDelete);
1068+
deletedDocumentIds.add(documentToDelete.getId());
1069+
}
1070+
return null;
1071+
}));
1072+
1073+
// Wait for 10 seconds, during which Watch will stop tracking the query and will send an
1074+
// existence filter rather than "delete" events when the query is resumed.
1075+
Thread.sleep(10000);
1076+
1077+
// Resume the query and save the resulting snapshot for verification.
1078+
QuerySnapshot snapshot2 = waitFor(collection.get());
1079+
1080+
// Verify that the snapshot from the resumed query contains the expected documents; that is,
1081+
// that it contains the 50 documents that were _not_ deleted.
1082+
// TODO(b/270731363): Remove the "if" condition below once the Firestore Emulator is fixed to
1083+
// send an existence filter. At the time of writing, the Firestore emulator fails to send an
1084+
// existence filter, resulting in the client including the deleted documents in the snapshot
1085+
// of the resumed query.
1086+
if (!(isRunningAgainstEmulator() && snapshot2.size() == 100)) {
1087+
HashSet<String> actualDocumentIds = new HashSet<>();
1088+
for (DocumentSnapshot documentSnapshot : snapshot2.getDocuments()) {
1089+
actualDocumentIds.add(documentSnapshot.getId());
1090+
}
1091+
HashSet<String> expectedDocumentIds = new HashSet<>();
1092+
for (DocumentReference documentRef : createdDocuments) {
1093+
if (!deletedDocumentIds.contains(documentRef.getId())) {
1094+
expectedDocumentIds.add(documentRef.getId());
1095+
}
1096+
}
1097+
assertWithMessage("snapshot2.docs")
1098+
.that(actualDocumentIds)
1099+
.containsExactlyElementsIn(expectedDocumentIds);
1100+
}
1101+
}
1102+
10331103
@Test
10341104
public void testOrQueries() {
10351105
Map<String, Map<String, Object>> testDocs =

firebase-firestore/src/androidTest/java/com/google/firebase/firestore/testutil/IntegrationTestUtil.java

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import com.google.firebase.firestore.ListenerRegistration;
3737
import com.google.firebase.firestore.MetadataChanges;
3838
import com.google.firebase.firestore.QuerySnapshot;
39+
import com.google.firebase.firestore.WriteBatch;
3940
import com.google.firebase.firestore.auth.User;
4041
import com.google.firebase.firestore.core.DatabaseInfo;
4142
import com.google.firebase.firestore.model.DatabaseId;
@@ -347,8 +348,27 @@ public static CollectionReference testCollectionWithDocs(Map<String, Map<String,
347348

348349
public static void writeAllDocs(
349350
CollectionReference collection, Map<String, Map<String, Object>> docs) {
351+
WriteBatch writeBatch = null;
352+
int writeBatchSize = 0;
353+
350354
for (Map.Entry<String, Map<String, Object>> doc : docs.entrySet()) {
351-
waitFor(collection.document(doc.getKey()).set(doc.getValue()));
355+
if (writeBatch == null) {
356+
writeBatch = collection.getFirestore().batch();
357+
}
358+
359+
writeBatch.set(collection.document(doc.getKey()), doc.getValue());
360+
writeBatchSize++;
361+
362+
// Write batches are capped at 500 writes. Use 400 just to be safe.
363+
if (writeBatchSize == 400) {
364+
waitFor(writeBatch.commit());
365+
writeBatch = null;
366+
writeBatchSize = 0;
367+
}
368+
}
369+
370+
if (writeBatch != null) {
371+
waitFor(writeBatch.commit());
352372
}
353373
}
354374

firebase-firestore/src/main/java/com/google/firebase/firestore/model/ServerTimestamps.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,16 @@ public static Value valueOf(Timestamp localWriteTime, @Nullable Value previousVa
6262
.putFields(TYPE_KEY, encodedType)
6363
.putFields(LOCAL_WRITE_TIME_KEY, encodeWriteTime);
6464

65+
// We should avoid storing deeply nested server timestamp map values, because we never use the
66+
// intermediate "previous values".
67+
// For example:
68+
// previous: 42L, add: t1, result: t1 -> 42L
69+
// previous: t1, add: t2, result: t2 -> 42L (NOT t2 -> t1 -> 42L)
70+
// previous: t2, add: t3, result: t3 -> 42L (NOT t3 -> t2 -> t1 -> 42L)
71+
// previous: t3, add: t4, result: t4 -> 42L (NOT t4 -> t3 -> t2 -> t1 -> 42L)
72+
// `getPreviousValue` recursively traverses server timestamps to find the least recent Value.
73+
previousValue =
74+
isServerTimestamp(previousValue) ? getPreviousValue(previousValue) : previousValue;
6575
if (previousValue != null) {
6676
mapRepresentation.putFields(PREVIOUS_VALUE_KEY, previousValue);
6777
}

firebase-firestore/src/test/java/com/google/firebase/firestore/local/SQLiteLocalStoreTest.java

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,29 @@
3232
import static java.util.Collections.emptyList;
3333
import static java.util.Collections.singletonList;
3434

35+
import com.google.firebase.Timestamp;
3536
import com.google.firebase.firestore.FieldValue;
3637
import com.google.firebase.firestore.core.Query;
38+
import com.google.firebase.firestore.model.DocumentKey;
3739
import com.google.firebase.firestore.model.FieldIndex;
40+
import com.google.firebase.firestore.model.FieldPath;
41+
import com.google.firebase.firestore.model.ObjectValue;
42+
import com.google.firebase.firestore.model.ServerTimestamps;
43+
import com.google.firebase.firestore.model.mutation.FieldMask;
44+
import com.google.firebase.firestore.model.mutation.FieldTransform;
45+
import com.google.firebase.firestore.model.mutation.PatchMutation;
46+
import com.google.firebase.firestore.model.mutation.Precondition;
47+
import com.google.firebase.firestore.model.mutation.ServerTimestampOperation;
48+
import com.google.firestore.v1.Value;
49+
import java.util.ArrayList;
3850
import java.util.Arrays;
3951
import java.util.Collection;
4052
import java.util.Collections;
53+
import java.util.HashMap;
54+
import java.util.HashSet;
55+
import java.util.List;
56+
import java.util.Map;
57+
import java.util.concurrent.atomic.AtomicReference;
4158
import org.junit.Test;
4259
import org.junit.runner.RunWith;
4360
import org.robolectric.RobolectricTestRunner;
@@ -290,4 +307,63 @@ public void testIndexesServerTimestamps() {
290307
assertOverlayTypes(keyMap("coll/a", CountingQueryEngine.OverlayType.Set));
291308
assertQueryReturned("coll/a");
292309
}
310+
311+
@Test
312+
public void testDeeplyNestedServerTimestamps() {
313+
Timestamp timestamp = Timestamp.now();
314+
Value initialServerTimestamp = ServerTimestamps.valueOf(timestamp, null);
315+
Map<String, Value> fields =
316+
new HashMap<String, Value>() {
317+
{
318+
put("timestamp", ServerTimestamps.valueOf(timestamp, initialServerTimestamp));
319+
}
320+
};
321+
FieldPath path = FieldPath.fromSingleSegment("timestamp");
322+
FieldMask mask =
323+
FieldMask.fromSet(
324+
new HashSet<FieldPath>() {
325+
{
326+
add(path);
327+
}
328+
});
329+
FieldTransform fieldTransform =
330+
new FieldTransform(path, ServerTimestampOperation.getInstance());
331+
List<FieldTransform> fieldTransforms =
332+
new ArrayList<FieldTransform>() {
333+
{
334+
add(fieldTransform);
335+
}
336+
};
337+
// The purpose of this test is to ensure that deeply nested server timestamps do not result in
338+
// a stack overflow error. Below we use a `Thread` object to create a large number of mutations
339+
// because the `Thread` class allows us to specify the maximum stack size.
340+
AtomicReference<Throwable> error = new AtomicReference<>();
341+
Thread thread =
342+
new Thread(
343+
Thread.currentThread().getThreadGroup(),
344+
() -> {
345+
try {
346+
for (int i = 0; i < 1000; ++i) {
347+
writeMutation(
348+
new PatchMutation(
349+
DocumentKey.fromPathString("some/object/for/test"),
350+
ObjectValue.fromMap(fields),
351+
mask,
352+
Precondition.NONE,
353+
fieldTransforms));
354+
}
355+
} catch (Throwable e) {
356+
error.set(e);
357+
}
358+
},
359+
/* name */ "test",
360+
/* stackSize */ 1024 * 1024);
361+
try {
362+
thread.start();
363+
thread.join();
364+
} catch (InterruptedException e) {
365+
throw new AssertionError(e);
366+
}
367+
assertThat(error.get()).isNull();
368+
}
293369
}

0 commit comments

Comments
 (0)