Skip to content

Commit b45353e

Browse files
Store the coerced base value in the BaseMutation (#564)
1 parent 9ac7870 commit b45353e

File tree

18 files changed

+327
-133
lines changed

18 files changed

+327
-133
lines changed

firebase-firestore/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
# Unreleased
2+
- [fixed] Fixed an internal assertion that was triggered when an
3+
update with a `FieldValue.serverTimestamp()` and an update with a
4+
`FieldValue.increment()` were pending for the same document (#491).
25
- [changed] Improved performance for queries with filters that only return a
36
small subset of the documents in a collection.
47
- [changed] Instead of failing silently, Firestore now crashes the client app

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

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import static com.google.firebase.firestore.testutil.TestUtil.map;
2020
import static org.junit.Assert.assertEquals;
2121
import static org.junit.Assert.assertFalse;
22+
import static org.junit.Assert.assertNotNull;
2223

2324
import androidx.test.ext.junit.runners.AndroidJUnit4;
2425
import com.google.android.gms.tasks.Tasks;
@@ -158,4 +159,55 @@ public void multipleDoubleIncrements() throws ExecutionException, InterruptedExc
158159
snap = accumulator.awaitRemoteEvent();
159160
assertEquals(0.111D, snap.getDouble("sum"), DOUBLE_EPSILON);
160161
}
162+
163+
@Test
164+
public void incrementTwiceInABatch() {
165+
writeInitialData(map("sum", "overwrite"));
166+
waitFor(
167+
docRef
168+
.getFirestore()
169+
.batch()
170+
.update(docRef, "sum", FieldValue.increment(1))
171+
.update(docRef, "sum", FieldValue.increment(1))
172+
.commit());
173+
expectLocalAndRemoteValue(2L);
174+
}
175+
176+
@Test
177+
public void incrementDeleteIncrementInABatch() {
178+
writeInitialData(map("sum", "overwrite"));
179+
waitFor(
180+
docRef
181+
.getFirestore()
182+
.batch()
183+
.update(docRef, "sum", FieldValue.increment(1))
184+
.update(docRef, "sum", FieldValue.delete())
185+
.update(docRef, "sum", FieldValue.increment(3))
186+
.commit());
187+
expectLocalAndRemoteValue(3L);
188+
}
189+
190+
@Test
191+
public void serverTimestampAndIncrement() throws ExecutionException, InterruptedException {
192+
// This test stacks two pending transforms (a ServerTimestamp and an Increment transform) and
193+
// reproduces the setup that was reported in
194+
// https://github.com/firebase/firebase-android-sdk/issues/491
195+
// In our original code, a NumericIncrementTransformOperation could cause us to decode the
196+
// ServerTimestamp as part of a PatchMutation, which triggered an assertion failure.
197+
Tasks.await(docRef.getFirestore().disableNetwork());
198+
199+
docRef.set(map("val", FieldValue.serverTimestamp()));
200+
docRef.set(map("val", FieldValue.increment(1)));
201+
202+
DocumentSnapshot snap = accumulator.awaitLocalEvent();
203+
assertNotNull(snap.getTimestamp("val", DocumentSnapshot.ServerTimestampBehavior.ESTIMATE));
204+
205+
snap = accumulator.awaitLocalEvent();
206+
assertEquals(1, (long) snap.getLong("val"));
207+
208+
Tasks.await(docRef.getFirestore().enableNetwork());
209+
210+
snap = accumulator.awaitRemoteEvent();
211+
assertEquals(1, (long) snap.getLong("val"));
212+
}
161213
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,12 @@ public void testServerTimestampsCanReturnPreviousValue() {
181181
DocumentSnapshot previousSnapshot = accumulator.awaitRemoteEvent();
182182
verifyTimestampsAreResolved(previousSnapshot);
183183

184+
// The following update includes an update of the nested map "deep", which updates it to contain
185+
// a single ServerTimestamp. As such, the update is split into two mutations: One that sets
186+
// "deep" to an empty map and overwrites the previous ServerTimestamp value and a second
187+
// transform that writes the new ServerTimestamp. This step in the test verifies that we can
188+
// still access the old ServerTimestamp value (from `previousSnapshot`) even though it was
189+
// removed in an intermediate step.
184190
waitFor(docRef.update(updateData));
185191
verifyTimestampsUsePreviousValue(accumulator.awaitLocalEvent(), previousSnapshot);
186192
verifyTimestampsAreResolved(accumulator.awaitRemoteEvent());

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

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828
import com.google.firebase.firestore.model.DocumentKey;
2929
import com.google.firebase.firestore.model.MaybeDocument;
3030
import com.google.firebase.firestore.model.SnapshotVersion;
31-
import com.google.firebase.firestore.model.mutation.FieldMask;
3231
import com.google.firebase.firestore.model.mutation.Mutation;
3332
import com.google.firebase.firestore.model.mutation.MutationBatch;
3433
import com.google.firebase.firestore.model.mutation.MutationBatchResult;
@@ -212,24 +211,17 @@ public LocalWriteResult writeLocally(List<Mutation> mutations) {
212211
// transform.
213212
List<Mutation> baseMutations = new ArrayList<>();
214213
for (Mutation mutation : mutations) {
215-
MaybeDocument maybeDocument = existingDocuments.get(mutation.getKey());
216-
if (!mutation.isIdempotent()) {
217-
// Theoretically, we should only include non-idempotent fields in this field mask as
218-
// this mask is used to populate the base state for all DocumentTransforms. By
219-
// including all fields, we incorrectly prevent rebasing of idempotent transforms
220-
// (such as `arrayUnion()`) when any non-idempotent transforms are present.
221-
FieldMask fieldMask = mutation.getFieldMask();
222-
if (fieldMask != null) {
223-
ObjectValue baseValues =
224-
(maybeDocument instanceof Document)
225-
? fieldMask.applyTo(((Document) maybeDocument).getData())
226-
: ObjectValue.emptyObject();
227-
// NOTE: The base state should only be applied if there's some existing
228-
// document to override, so use a Precondition of exists=true
229-
baseMutations.add(
230-
new PatchMutation(
231-
mutation.getKey(), baseValues, fieldMask, Precondition.exists(true)));
232-
}
214+
ObjectValue baseValue =
215+
mutation.extractBaseValue(existingDocuments.get(mutation.getKey()));
216+
if (baseValue != null) {
217+
// NOTE: The base state should only be applied if there's some existing
218+
// document to override, so use a Precondition of exists=true
219+
baseMutations.add(
220+
new PatchMutation(
221+
mutation.getKey(),
222+
baseValue,
223+
baseValue.getFieldMask(),
224+
Precondition.exists(true)));
233225
}
234226
}
235227

firebase-firestore/src/main/java/com/google/firebase/firestore/model/mutation/ArrayTransformOperation.java

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,17 +39,24 @@ public List<FieldValue> getElements() {
3939
}
4040

4141
@Override
42-
public FieldValue applyToLocalView(FieldValue previousValue, Timestamp localWriteTime) {
42+
public FieldValue applyToLocalView(@Nullable FieldValue previousValue, Timestamp localWriteTime) {
4343
return apply(previousValue);
4444
}
4545

4646
@Override
47-
public FieldValue applyToRemoteDocument(FieldValue previousValue, FieldValue transformResult) {
47+
public FieldValue applyToRemoteDocument(
48+
@Nullable FieldValue previousValue, FieldValue transformResult) {
4849
// The server just sends null as the transform result for array operations, so we have to
4950
// calculate a result the same as we do for local applications.
5051
return apply(previousValue);
5152
}
5253

54+
@Override
55+
@Nullable
56+
public FieldValue computeBaseValue(@Nullable FieldValue currentValue) {
57+
return null; // Array transforms are idempotent and don't require a base value.
58+
}
59+
5360
@Override
5461
@SuppressWarnings("EqualsGetClass") // subtype-sensitive equality is intended.
5562
public boolean equals(Object o) {
@@ -73,7 +80,7 @@ public int hashCode() {
7380
}
7481

7582
/** Applies this ArrayTransformOperation against the specified previousValue. */
76-
protected abstract ArrayValue apply(FieldValue previousValue);
83+
protected abstract ArrayValue apply(@Nullable FieldValue previousValue);
7784

7885
/**
7986
* Inspects the provided value, returning an ArrayList copy of the internal array if it's an
@@ -88,19 +95,14 @@ static ArrayList<FieldValue> coercedFieldValuesArray(@Nullable FieldValue value)
8895
}
8996
}
9097

91-
@Override
92-
public boolean isIdempotent() {
93-
return true;
94-
}
95-
9698
/** An array union transform operation. */
9799
public static class Union extends ArrayTransformOperation {
98100
public Union(List<FieldValue> elements) {
99101
super(elements);
100102
}
101103

102104
@Override
103-
protected ArrayValue apply(FieldValue previousValue) {
105+
protected ArrayValue apply(@Nullable FieldValue previousValue) {
104106
ArrayList<FieldValue> result = coercedFieldValuesArray(previousValue);
105107
for (FieldValue element : getElements()) {
106108
if (!result.contains(element)) {
@@ -118,7 +120,7 @@ public Remove(List<FieldValue> elements) {
118120
}
119121

120122
@Override
121-
protected ArrayValue apply(FieldValue previousValue) {
123+
protected ArrayValue apply(@Nullable FieldValue previousValue) {
122124
ArrayList<FieldValue> result = coercedFieldValuesArray(previousValue);
123125
for (FieldValue element : getElements()) {
124126
result.removeAll(Collections.singleton(element));

firebase-firestore/src/main/java/com/google/firebase/firestore/model/mutation/DeleteMutation.java

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import com.google.firebase.firestore.model.MaybeDocument;
2222
import com.google.firebase.firestore.model.NoDocument;
2323
import com.google.firebase.firestore.model.SnapshotVersion;
24+
import com.google.firebase.firestore.model.value.ObjectValue;
2425
import javax.annotation.Nullable;
2526

2627
/** Represents a Delete operation */
@@ -85,12 +86,7 @@ public MaybeDocument applyToLocalView(
8586

8687
@Nullable
8788
@Override
88-
public FieldMask getFieldMask() {
89+
public ObjectValue extractBaseValue(@Nullable MaybeDocument maybeDoc) {
8990
return null;
9091
}
91-
92-
@Override
93-
public boolean isIdempotent() {
94-
return true;
95-
}
9692
}

firebase-firestore/src/main/java/com/google/firebase/firestore/model/mutation/FieldMask.java

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,6 @@
1515
package com.google.firebase.firestore.model.mutation;
1616

1717
import com.google.firebase.firestore.model.FieldPath;
18-
import com.google.firebase.firestore.model.value.FieldValue;
19-
import com.google.firebase.firestore.model.value.ObjectValue;
2018
import java.util.Set;
2119

2220
/**
@@ -71,25 +69,6 @@ public boolean covers(FieldPath fieldPath) {
7169
return false;
7270
}
7371

74-
/**
75-
* Applies this field mask to the provided object value and returns an object that only contains
76-
* fields that are specified in both the input object and this field mask.
77-
*/
78-
public ObjectValue applyTo(ObjectValue data) {
79-
ObjectValue filteredObject = ObjectValue.emptyObject();
80-
for (FieldPath path : mask) {
81-
if (path.isEmpty()) {
82-
return data;
83-
} else {
84-
FieldValue newValue = data.get(path);
85-
if (newValue != null) {
86-
filteredObject = filteredObject.set(path, newValue);
87-
}
88-
}
89-
}
90-
return filteredObject;
91-
}
92-
9372
@Override
9473
public int hashCode() {
9574
return mask.hashCode();

firebase-firestore/src/main/java/com/google/firebase/firestore/model/mutation/FieldTransform.java

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,4 @@ public int hashCode() {
5858
result = 31 * result + operation.hashCode();
5959
return result;
6060
}
61-
62-
public boolean isIdempotent() {
63-
return operation.isIdempotent();
64-
}
6561
}

firebase-firestore/src/main/java/com/google/firebase/firestore/model/mutation/Mutation.java

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import com.google.firebase.firestore.model.DocumentKey;
2222
import com.google.firebase.firestore.model.MaybeDocument;
2323
import com.google.firebase.firestore.model.SnapshotVersion;
24+
import com.google.firebase.firestore.model.value.ObjectValue;
2425
import javax.annotation.Nullable;
2526

2627
/**
@@ -113,6 +114,23 @@ public abstract MaybeDocument applyToRemoteDocument(
113114
public abstract MaybeDocument applyToLocalView(
114115
@Nullable MaybeDocument maybeDoc, @Nullable MaybeDocument baseDoc, Timestamp localWriteTime);
115116

117+
/**
118+
* If applicable, returns the base value to persist with this mutation. If a base value is
119+
* provided, the mutation is always applied to this base value, even if document has already been
120+
* updated.
121+
*
122+
* <p>The base value is a sparse object that consists of only the document fields for which this
123+
* mutation contains a non-idempotent transformation (e.g. a numeric increment). The provided
124+
* value guarantees consistent behavior for non-idempotent transforms and allow us to return the
125+
* same latency-compensated value even if the backend has already applied the mutation. The base
126+
* value is null for idempotent mutations, as they can be re-played even if the backend has
127+
* already applied them.
128+
*
129+
* @return a base value to store along with the mutation, or null for idempotent mutations.
130+
*/
131+
@Nullable
132+
public abstract ObjectValue extractBaseValue(@Nullable MaybeDocument maybeDoc);
133+
116134
/** Helper for derived classes to implement .equals(). */
117135
boolean hasSameKeyAndPrecondition(Mutation other) {
118136
return key.equals(other.key) && precondition.equals(other.precondition);
@@ -148,15 +166,4 @@ static SnapshotVersion getPostMutationVersion(@Nullable MaybeDocument maybeDoc)
148166
return SnapshotVersion.NONE;
149167
}
150168
}
151-
152-
/**
153-
* If applicable, returns the field mask for this mutation. Fields that are not included in this
154-
* field mask are not modified when this mutation is applied. Mutations that replace all document
155-
* values return 'null'.
156-
*/
157-
@Nullable
158-
public abstract FieldMask getFieldMask();
159-
160-
/** Returns whether all operations in the mutation are idempotent. */
161-
public abstract boolean isIdempotent();
162169
}

firebase-firestore/src/main/java/com/google/firebase/firestore/model/mutation/NumericIncrementTransformOperation.java

Lines changed: 34 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
package com.google.firebase.firestore.model.mutation;
1616

1717
import static com.google.firebase.firestore.util.Assert.fail;
18+
import static com.google.firebase.firestore.util.Assert.hardAssert;
1819

20+
import androidx.annotation.Nullable;
1921
import com.google.firebase.Timestamp;
2022
import com.google.firebase.firestore.model.value.DoubleValue;
2123
import com.google.firebase.firestore.model.value.FieldValue;
@@ -35,23 +37,47 @@ public NumericIncrementTransformOperation(NumberValue operand) {
3537
}
3638

3739
@Override
38-
public FieldValue applyToLocalView(FieldValue previousValue, Timestamp localWriteTime) {
40+
public FieldValue applyToLocalView(@Nullable FieldValue previousValue, Timestamp localWriteTime) {
41+
NumberValue baseValue = computeBaseValue(previousValue);
42+
3943
// Return an integer value only if the previous value and the operand is an integer.
40-
if (previousValue instanceof IntegerValue && operand instanceof IntegerValue) {
41-
long sum = safeIncrement(((IntegerValue) previousValue).getInternalValue(), operandAsLong());
44+
if (baseValue instanceof IntegerValue && operand instanceof IntegerValue) {
45+
long sum = safeIncrement(((IntegerValue) baseValue).getInternalValue(), operandAsLong());
4246
return IntegerValue.valueOf(sum);
43-
} else if (previousValue instanceof IntegerValue) {
44-
double sum = ((IntegerValue) previousValue).getInternalValue() + operandAsDouble();
47+
} else if (baseValue instanceof IntegerValue) {
48+
double sum = ((IntegerValue) baseValue).getInternalValue() + operandAsDouble();
4549
return DoubleValue.valueOf(sum);
46-
} else if (previousValue instanceof DoubleValue) {
47-
double sum = ((DoubleValue) previousValue).getInternalValue() + operandAsDouble();
50+
} else {
51+
hardAssert(
52+
baseValue instanceof DoubleValue,
53+
"Expected NumberValue to be of type DoubleValue, but was ",
54+
previousValue.getClass().getCanonicalName());
55+
double sum = ((DoubleValue) baseValue).getInternalValue() + operandAsDouble();
4856
return DoubleValue.valueOf(sum);
4957
}
58+
}
59+
60+
@Override
61+
public FieldValue applyToRemoteDocument(
62+
@Nullable FieldValue previousValue, FieldValue transformResult) {
63+
return transformResult;
64+
}
5065

51-
// If the existing value is not a number, use the value of the transform as the new base value.
66+
public FieldValue getOperand() {
5267
return operand;
5368
}
5469

70+
/**
71+
* Inspects the provided value, returning the provided value if it is already a NumberValue,
72+
* otherwise returning a coerced IntegerValue of 0.
73+
*/
74+
@Override
75+
public NumberValue computeBaseValue(@Nullable FieldValue previousValue) {
76+
return previousValue instanceof NumberValue
77+
? (NumberValue) previousValue
78+
: IntegerValue.valueOf(0L);
79+
}
80+
5581
/**
5682
* Implementation of Java 8's `addExact()` that resolves positive and negative numeric overflows
5783
* to Long.MAX_VALUE or Long.MIN_VALUE respectively (instead of throwing an ArithmeticException).
@@ -94,18 +120,4 @@ private long operandAsLong() {
94120
+ operand.getClass().getCanonicalName());
95121
}
96122
}
97-
98-
@Override
99-
public FieldValue applyToRemoteDocument(FieldValue previousValue, FieldValue transformResult) {
100-
return transformResult;
101-
}
102-
103-
public FieldValue getOperand() {
104-
return operand;
105-
}
106-
107-
@Override
108-
public boolean isIdempotent() {
109-
return false;
110-
}
111123
}

0 commit comments

Comments
 (0)