Skip to content

Commit f2f6eef

Browse files
Store the coerced base value in the BaseMutation (#1928)
1 parent e8900fe commit f2f6eef

File tree

10 files changed

+326
-112
lines changed

10 files changed

+326
-112
lines changed

packages/firestore/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11

22
# Unreleased
3+
- [fixed] Fixed an internal assertion that was triggered when an update
4+
with a `FieldValue.serverTimestamp()` and an update with a
5+
`FieldValue.increment()` were pending for the same document.
36
- [feature] Added `clearPersistence()`, which clears the persistent storage
47
including pending writes and cached documents. This is intended to help
58
write reliable tests (#449).

packages/firestore/src/local/local_store.ts

Lines changed: 16 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import {
2727
maybeDocumentMap,
2828
MaybeDocumentMap
2929
} from '../model/collections';
30-
import { Document, MaybeDocument } from '../model/document';
30+
import { MaybeDocument } from '../model/document';
3131
import { DocumentKey } from '../model/document_key';
3232
import { Mutation, PatchMutation, Precondition } from '../model/mutation';
3333
import {
@@ -40,7 +40,6 @@ import { assert } from '../util/assert';
4040
import * as log from '../util/log';
4141
import * as objUtils from '../util/obj';
4242

43-
import { ObjectValue } from '../model/field_value';
4443
import { LocalDocumentsView } from './local_documents_view';
4544
import { LocalViewChanges } from './local_view_changes';
4645
import { LruGarbageCollector, LruResults } from './lru_garbage_collector';
@@ -272,32 +271,21 @@ export class LocalStore {
272271
const baseMutations: Mutation[] = [];
273272

274273
for (const mutation of mutations) {
275-
const maybeDoc = existingDocs.get(mutation.key);
276-
if (!mutation.isIdempotent) {
277-
// Theoretically, we should only include non-idempotent fields
278-
// in this field mask as this mask is used to populate the base
279-
// state for all DocumentTransforms. By including all fields,
280-
// we incorrectly prevent rebasing of idempotent transforms
281-
// (such as `arrayUnion()`) when any non-idempotent transforms
282-
// are present.
283-
const fieldMask = mutation.fieldMask;
284-
if (fieldMask) {
285-
const baseValues =
286-
maybeDoc instanceof Document
287-
? fieldMask.applyTo(maybeDoc.data)
288-
: ObjectValue.EMPTY;
289-
// NOTE: The base state should only be applied if there's some
290-
// existing document to override, so use a Precondition of
291-
// exists=true
292-
baseMutations.push(
293-
new PatchMutation(
294-
mutation.key,
295-
baseValues,
296-
fieldMask,
297-
Precondition.exists(true)
298-
)
299-
);
300-
}
274+
const baseValue = mutation.extractBaseValue(
275+
existingDocs.get(mutation.key)
276+
);
277+
if (baseValue != null) {
278+
// NOTE: The base state should only be applied if there's some
279+
// existing document to override, so use a Precondition of
280+
// exists=true
281+
baseMutations.push(
282+
new PatchMutation(
283+
mutation.key,
284+
baseValue,
285+
baseValue.fieldMask(),
286+
Precondition.exists(true)
287+
)
288+
);
301289
}
302290
}
303291

packages/firestore/src/model/field_value.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,11 @@ import { Timestamp } from '../api/timestamp';
2222
import { DatabaseId } from '../core/database_info';
2323
import { assert, fail } from '../util/assert';
2424
import { primitiveComparator } from '../util/misc';
25-
import { SortedMap } from '../util/sorted_map';
26-
2725
import { DocumentKey } from './document_key';
26+
import { FieldMask } from './mutation';
2827
import { FieldPath } from './path';
28+
import { SortedMap } from '../util/sorted_map';
29+
import { SortedSet } from '../util/sorted_set';
2930

3031
/**
3132
* Supported data value types:
@@ -605,6 +606,34 @@ export class ObjectValue extends FieldValue {
605606
return field;
606607
}
607608

609+
/**
610+
* Returns a FieldMask built from all FieldPaths starting from this ObjectValue,
611+
* including paths from nested objects.
612+
*/
613+
fieldMask(): FieldMask {
614+
let fields = new SortedSet<FieldPath>(FieldPath.comparator);
615+
this.internalValue.forEach((key, value) => {
616+
const currentPath = new FieldPath([key]);
617+
if (value instanceof ObjectValue) {
618+
const nestedMask = value.fieldMask();
619+
const nestedFields = nestedMask.fields;
620+
if (nestedFields.isEmpty()) {
621+
// Preserve the empty map by adding it to the FieldMask.
622+
fields = fields.add(currentPath);
623+
} else {
624+
// For nested and non-empty ObjectValues, add the FieldPath of the
625+
// leaf nodes.
626+
nestedFields.forEach(nestedPath => {
627+
fields = fields.add(currentPath.child(nestedPath));
628+
});
629+
}
630+
} else {
631+
fields = fields.add(currentPath);
632+
}
633+
});
634+
return FieldMask.fromSet(fields);
635+
}
636+
608637
toString(): string {
609638
return this.internalValue.toString();
610639
}

packages/firestore/src/model/mutation.ts

Lines changed: 56 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -73,27 +73,6 @@ export class FieldMask {
7373
return found;
7474
}
7575

76-
/**
77-
* Applies this field mask to the provided object value and returns an object
78-
* that only contains fields that are specified in both the input object and
79-
* this field mask.
80-
*/
81-
applyTo(data: ObjectValue): ObjectValue {
82-
let filteredObject = ObjectValue.EMPTY;
83-
this.fields.forEach(fieldMaskPath => {
84-
if (fieldMaskPath.isEmpty()) {
85-
return data;
86-
} else {
87-
const newValue = data.field(fieldMaskPath);
88-
if (newValue !== null) {
89-
filteredObject = filteredObject.set(fieldMaskPath, newValue);
90-
}
91-
}
92-
});
93-
94-
return filteredObject;
95-
}
96-
9776
isEqual(other: FieldMask): boolean {
9877
return this.fields.isEqual(other.fields);
9978
}
@@ -106,11 +85,6 @@ export class FieldTransform {
10685
readonly transform: TransformOperation
10786
) {}
10887

109-
/** Whether this field transform is idempotent. */
110-
get isIdempotent(): boolean {
111-
return this.transform.isIdempotent;
112-
}
113-
11488
isEqual(other: FieldTransform): boolean {
11589
return (
11690
this.field.isEqual(other.field) && this.transform.isEqual(other.transform)
@@ -302,17 +276,25 @@ export abstract class Mutation {
302276
localWriteTime: Timestamp
303277
): MaybeDocument | null;
304278

305-
abstract isEqual(other: Mutation): boolean;
306-
307279
/**
308-
* If applicable, returns the field mask for this mutation. Fields that are
309-
* not included in this field mask are not modified when this mutation is
310-
* applied. Mutations that replace the entire document return 'null'.
280+
* If this mutation is not idempotent, returns the base value to persist with
281+
* this mutation. If a base value is returned, the mutation is always applied
282+
* to this base value, even if document has already been updated.
283+
*
284+
* The base value is a sparse object that consists of only the document
285+
* fields for which this mutation contains a non-idempotent transformation
286+
* (e.g. a numeric increment). The provided alue guarantees consistent
287+
* behavior for non-idempotent transforms and allow us to return the same
288+
* latency-compensated value even if the backend has already applied the
289+
* mutation. The base value is null for idempotent mutations, as they can be
290+
* re-played even if the backend has already applied them.
291+
*
292+
* @return a base value to store along with the mutation, or null for
293+
* idempotent mutations.
311294
*/
312-
abstract get fieldMask(): FieldMask | null;
295+
abstract extractBaseValue(maybeDoc: MaybeDocument | null): ObjectValue | null;
313296

314-
/** Returns whether all operations in the mutation are idempotent. */
315-
abstract get isIdempotent(): boolean;
297+
abstract isEqual(other: Mutation): boolean;
316298

317299
protected verifyKeyMatches(maybeDoc: MaybeDocument | null): void {
318300
if (maybeDoc != null) {
@@ -393,11 +375,7 @@ export class SetMutation extends Mutation {
393375
});
394376
}
395377

396-
get isIdempotent(): true {
397-
return true;
398-
}
399-
400-
get fieldMask(): null {
378+
extractBaseValue(maybeDoc: MaybeDocument | null): null {
401379
return null;
402380
}
403381

@@ -479,8 +457,8 @@ export class PatchMutation extends Mutation {
479457
});
480458
}
481459

482-
get isIdempotent(): true {
483-
return true;
460+
extractBaseValue(maybeDoc: MaybeDocument | null): null {
461+
return null;
484462
}
485463

486464
isEqual(other: Mutation): boolean {
@@ -592,6 +570,7 @@ export class TransformMutation extends Mutation {
592570
const doc = this.requireDocument(maybeDoc);
593571
const transformResults = this.localTransformResults(
594572
localWriteTime,
573+
maybeDoc,
595574
baseDoc
596575
);
597576
const newData = this.transformObject(doc.data, transformResults);
@@ -600,22 +579,29 @@ export class TransformMutation extends Mutation {
600579
});
601580
}
602581

603-
get isIdempotent(): boolean {
582+
extractBaseValue(maybeDoc: MaybeDocument | null): ObjectValue | null {
583+
let baseObject: ObjectValue | null = null;
604584
for (const fieldTransform of this.fieldTransforms) {
605-
if (!fieldTransform.isIdempotent) {
606-
return false;
585+
const existingValue =
586+
maybeDoc instanceof Document
587+
? maybeDoc.field(fieldTransform.field)
588+
: undefined;
589+
const coercedValue = fieldTransform.transform.computeBaseValue(
590+
existingValue || null
591+
);
592+
593+
if (coercedValue != null) {
594+
if (baseObject == null) {
595+
baseObject = ObjectValue.EMPTY.set(
596+
fieldTransform.field,
597+
coercedValue
598+
);
599+
} else {
600+
baseObject = baseObject.set(fieldTransform.field, coercedValue);
601+
}
607602
}
608603
}
609-
610-
return true;
611-
}
612-
613-
get fieldMask(): FieldMask {
614-
let fieldMask = new SortedSet<FieldPath>(FieldPath.comparator);
615-
this.fieldTransforms.forEach(
616-
transform => (fieldMask = fieldMask.add(transform.field))
617-
);
618-
return new FieldMask(fieldMask);
604+
return baseObject;
619605
}
620606

621607
isEqual(other: Mutation): boolean {
@@ -690,19 +676,30 @@ export class TransformMutation extends Mutation {
690676
*
691677
* @param localWriteTime The local time of the transform mutation (used to
692678
* generate ServerTimestampValues).
679+
* @param maybeDoc The current state of the document after applying all
680+
* previous mutations.
693681
* @param baseDoc The document prior to applying this mutation batch.
694682
* @return The transform results list.
695683
*/
696684
private localTransformResults(
697685
localWriteTime: Timestamp,
686+
maybeDoc: MaybeDocument | null,
698687
baseDoc: MaybeDocument | null
699688
): FieldValue[] {
700689
const transformResults = [] as FieldValue[];
701690
for (const fieldTransform of this.fieldTransforms) {
702691
const transform = fieldTransform.transform;
703692

704693
let previousValue: FieldValue | null = null;
705-
if (baseDoc instanceof Document) {
694+
if (maybeDoc instanceof Document) {
695+
previousValue = maybeDoc.field(fieldTransform.field);
696+
}
697+
698+
if (previousValue === null && baseDoc instanceof Document) {
699+
// If the current document does not contain a value for the mutated
700+
// field, use the value that existed before applying this mutation
701+
// batch. This solves an edge case where a PatchMutation clears the
702+
// values in a nested map before the TransformMutation is applied.
706703
previousValue = baseDoc.field(fieldTransform.field);
707704
}
708705

@@ -779,19 +776,15 @@ export class DeleteMutation extends Mutation {
779776
return new NoDocument(this.key, SnapshotVersion.forDeletedDoc());
780777
}
781778

779+
extractBaseValue(maybeDoc: MaybeDocument | null): null {
780+
return null;
781+
}
782+
782783
isEqual(other: Mutation): boolean {
783784
return (
784785
other instanceof DeleteMutation &&
785786
this.key.isEqual(other.key) &&
786787
this.precondition.isEqual(other.precondition)
787788
);
788789
}
789-
790-
get isIdempotent(): true {
791-
return true;
792-
}
793-
794-
get fieldMask(): null {
795-
return null;
796-
}
797790
}

0 commit comments

Comments
 (0)