Skip to content

Don't deserialize full Document proto for Query execution #2115

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Aug 29, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/firestore/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

# Unreleased (1.5.0)
- [feature] Added a `Firestore.waitForPendingWrites()` method that
allows users to wait until all pending writes are acknowledged by the
Expand All @@ -7,6 +6,8 @@
the instance, releasing any held resources. Once it completes, you can
optionally call `Firestore.clearPersistence()` to wipe persisted Firestore
data from disk.
- [changed] Improved performance for queries with filters that only return a
small subset of the documents in a collection.

# 1.4.10
- [changed] Transactions now perform exponential backoff before retrying.
Expand All @@ -25,6 +26,7 @@
match the query (https://github.com/firebase/firebase-android-sdk/issues/155).

# 1.4.4
>>>>>>> master
- [fixed] Fixed an internal assertion that was triggered when an update
with a `FieldValue.serverTimestamp()` and an update with a
`FieldValue.increment()` were pending for the same document.
Expand Down
8 changes: 4 additions & 4 deletions packages/firestore/src/api/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1276,7 +1276,7 @@ export class DocumentSnapshot implements firestore.DocumentSnapshot {
return !this._document
? undefined
: this.convertObject(
this._document.data,
this._document.data(),
FieldValueOptions.fromSnapshotOptions(
options,
this._firestore._areTimestampsInSnapshotsEnabled()
Expand All @@ -1291,9 +1291,9 @@ export class DocumentSnapshot implements firestore.DocumentSnapshot {
validateBetweenNumberOfArgs('DocumentSnapshot.get', arguments, 1, 2);
options = validateSnapshotOptions('DocumentSnapshot.get', options);
if (this._document) {
const value = this._document.data.field(
fieldPathFromArgument('DocumentSnapshot.get', fieldPath)
);
const value = this._document
.data()
.field(fieldPathFromArgument('DocumentSnapshot.get', fieldPath));
if (value !== null) {
return this.convertValue(
value,
Expand Down
2 changes: 1 addition & 1 deletion packages/firestore/src/core/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -759,7 +759,7 @@ export class Bound {
} else {
const docValue = doc.field(orderByComponent.field);
assert(
docValue !== undefined,
docValue !== null,
'Field should exist since document matched the orderBy already.'
);
comparison = component.compareTo(docValue!);
Expand Down
2 changes: 1 addition & 1 deletion packages/firestore/src/core/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ export class View {

// Calculate change
if (oldDoc && newDoc) {
const docsEqual = oldDoc.data.isEqual(newDoc.data);
const docsEqual = oldDoc.data().isEqual(newDoc.data());
if (!docsEqual) {
if (!this.shouldWaitForSyncedDocument(oldDoc, newDoc)) {
changeSet.track({
Expand Down
100 changes: 86 additions & 14 deletions packages/firestore/src/model/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,14 @@
*/

import { SnapshotVersion } from '../core/snapshot_version';
import { fail } from '../util/assert';
import { assert, fail } from '../util/assert';

import { DocumentKey } from './document_key';
import { FieldValue, JsonObject, ObjectValue } from './field_value';
import { FieldPath } from './path';

import * as api from '../protos/firestore_proto_api';
import * as obj from '../util/obj';

export interface DocumentOptions {
hasLocalMutations?: boolean;
Expand Down Expand Up @@ -59,43 +60,91 @@ export class Document extends MaybeDocument {
readonly hasLocalMutations: boolean;
readonly hasCommittedMutations: boolean;

/**
* A cache of canonicalized FieldPaths to FieldValues that have already been
* deserialized in `getField()`.
*/
private fieldValueCache?: Map<string, FieldValue | null>;

constructor(
key: DocumentKey,
version: SnapshotVersion,
readonly data: ObjectValue,
options: DocumentOptions,
/**
* Memoized serialized form of the document for optimization purposes (avoids repeated
* serialization). Might be undefined.
*/
readonly proto?: api.Document
private objectValue?: ObjectValue,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it ever valid to pass both the objectValue and the proto/converter pair? If not, would it be worthwhile to assert that either only objectValue or else both proto and converter are given? (perhaps that could supersede the assertions inside field and data)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It theoretically allows you to memoize both the proto and the objectValue, which helps when we write an object back to persistence. I changed the asserts in this PR to assert in the constructor that proto and converter are set if objectValue is not defined.

readonly proto?: api.Document,
private readonly converter?: (value: api.Value) => FieldValue
) {
super(key, version);
assert(
this.objectValue !== undefined ||
(this.proto !== undefined && this.converter !== undefined),
'If objectValue is not defined, proto and converter need to be set.'
);

this.hasLocalMutations = !!options.hasLocalMutations;
this.hasCommittedMutations = !!options.hasCommittedMutations;
}

field(path: FieldPath): FieldValue | null {
return this.data.field(path);
if (this.objectValue) {
return this.objectValue.field(path);
} else {
if (!this.fieldValueCache) {
// TODO(b/136090445): Remove the cache when `getField` is no longer
// called during Query ordering.
this.fieldValueCache = new Map<string, FieldValue>();
}

const canonicalPath = path.canonicalString();

let fieldValue = this.fieldValueCache.get(canonicalPath);

if (fieldValue === undefined) {
// Instead of deserializing the full Document proto, we only
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function is pretty long now. Perhaps split this part into a helper function?

if (fieldValue === undefined) {
  return parseFieldValue(path, canonicalPath);
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved the code that extract the Proto value from the protobuf into a separate function, which also helps with the control flow of the loop.

// deserialize the value at the requested field path. This speeds up
// Query execution as query filters can discard documents based on a
// single field.
const protoValue = this.getProtoField(path);
if (protoValue === undefined) {
fieldValue = null;
} else {
fieldValue = this.converter!(protoValue);
}
this.fieldValueCache.set(canonicalPath, fieldValue);
}

return fieldValue!;
}
}

fieldValue(path: FieldPath): unknown {
const field = this.field(path);
return field ? field.value() : undefined;
data(): ObjectValue {
if (!this.objectValue) {
let result = ObjectValue.EMPTY;
obj.forEach(this.proto!.fields, (key: string, value: api.Value) => {
result = result.set(new FieldPath([key]), this.converter!(value));
});
this.objectValue = result;

// Once objectValue is computed, values inside the fieldValueCache are no
// longer accessed.
this.fieldValueCache = undefined;
}

return this.objectValue;
}

value(): JsonObject<unknown> {
return this.data.value();
return this.data().value();
}

isEqual(other: MaybeDocument | null | undefined): boolean {
return (
other instanceof Document &&
this.key.isEqual(other.key) &&
this.version.isEqual(other.version) &&
this.data.isEqual(other.data) &&
this.hasLocalMutations === other.hasLocalMutations &&
this.hasCommittedMutations === other.hasCommittedMutations
this.hasCommittedMutations === other.hasCommittedMutations &&
this.data().isEqual(other.data())
);
}

Expand All @@ -111,6 +160,29 @@ export class Document extends MaybeDocument {
return this.hasLocalMutations || this.hasCommittedMutations;
}

/**
* Returns the nested Protobuf value for 'path`. Can only be called if
* `proto` was provided at construction time.
*/
private getProtoField(path: FieldPath): api.Value | undefined {
assert(
this.proto !== undefined,
'Can only call getProtoField() when proto is defined'
);

let protoValue: api.Value | undefined = this.proto!.fields[
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: add an empty line after assert?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

path.firstSegment()
];
for (let i = 1; i < path.length; ++i) {
if (!protoValue || !protoValue.mapValue) {
return undefined;
}
protoValue = protoValue.mapValue.fields[path.get(i)];
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: add an empty line right after the loop?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done


return protoValue;
}

static compareByField(field: FieldPath, d1: Document, d2: Document): number {
const v1 = d1.field(field);
const v2 = d2.field(field);
Expand Down
72 changes: 51 additions & 21 deletions packages/firestore/src/model/mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,9 +353,14 @@ export class SetMutation extends Mutation {
// have held.

const version = mutationResult.version;
return new Document(this.key, version, this.value, {
hasCommittedMutations: true
});
return new Document(
this.key,
version,
{
hasCommittedMutations: true
},
this.value
);
}

applyToLocalView(
Expand All @@ -370,9 +375,14 @@ export class SetMutation extends Mutation {
}

const version = Mutation.getPostMutationVersion(maybeDoc);
return new Document(this.key, version, this.value, {
hasLocalMutations: true
});
return new Document(
this.key,
version,
{
hasLocalMutations: true
},
this.value
);
}

extractBaseValue(maybeDoc: MaybeDocument | null): null {
Expand Down Expand Up @@ -434,9 +444,14 @@ export class PatchMutation extends Mutation {
}

const newData = this.patchDocument(maybeDoc);
return new Document(this.key, mutationResult.version, newData, {
hasCommittedMutations: true
});
return new Document(
this.key,
mutationResult.version,
{
hasCommittedMutations: true
},
newData
);
}

applyToLocalView(
Expand All @@ -452,9 +467,14 @@ export class PatchMutation extends Mutation {

const version = Mutation.getPostMutationVersion(maybeDoc);
const newData = this.patchDocument(maybeDoc);
return new Document(this.key, version, newData, {
hasLocalMutations: true
});
return new Document(
this.key,
version,
{
hasLocalMutations: true
},
newData
);
}

extractBaseValue(maybeDoc: MaybeDocument | null): null {
Expand All @@ -478,7 +498,7 @@ export class PatchMutation extends Mutation {
private patchDocument(maybeDoc: MaybeDocument | null): ObjectValue {
let data: ObjectValue;
if (maybeDoc instanceof Document) {
data = maybeDoc.data;
data = maybeDoc.data();
} else {
data = ObjectValue.EMPTY;
}
Expand Down Expand Up @@ -550,10 +570,15 @@ export class TransformMutation extends Mutation {
);

const version = mutationResult.version;
const newData = this.transformObject(doc.data, transformResults);
return new Document(this.key, version, newData, {
hasCommittedMutations: true
});
const newData = this.transformObject(doc.data(), transformResults);
return new Document(
this.key,
version,
{
hasCommittedMutations: true
},
newData
);
}

applyToLocalView(
Expand All @@ -573,10 +598,15 @@ export class TransformMutation extends Mutation {
maybeDoc,
baseDoc
);
const newData = this.transformObject(doc.data, transformResults);
return new Document(this.key, doc.version, newData, {
hasLocalMutations: true
});
const newData = this.transformObject(doc.data(), transformResults);
return new Document(
this.key,
doc.version,
{
hasLocalMutations: true
},
newData
);
}

extractBaseValue(maybeDoc: MaybeDocument | null): ObjectValue | null {
Expand Down
Loading