Skip to content

Remove Held Write Acks #1156

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 16 commits into from
Sep 5, 2018
Merged

Remove Held Write Acks #1156

merged 16 commits into from
Sep 5, 2018

Conversation

schmidt-sebastian
Copy link
Contributor

@schmidt-sebastian schmidt-sebastian commented Aug 22, 2018

This PR removes held write acks. Instead, we store the mutation result directly in IndexedDb as the Write stream acknowledges a mutation. These documents are marked with hasCommittedMutations.

For documents with hasCommittedMutations which are part of the initial View (modified before the query was added), we don't raise hasPendingWrites. Only mutations that are acknowledged during the lifetime of a query raise hasPendingWrites. This is meant to mimic the previous behavior, since we did not hold writes when we did not have queries.

When we don't have a base document, patches that can't be applied are stored as UnknownDocuments until Watch catches up.

Existing held write acks are dropped via schema migration. This PR removes all other write ack handling.

constructor(readonly key: DocumentKey, readonly version: SnapshotVersion) {}
constructor(
readonly key: DocumentKey,
readonly remoteVersion: SnapshotVersion
Copy link
Contributor

Choose a reason for hiding this comment

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

What's a "remoteVersion"? Both the watch-stream-assigned read_time and the write-stream-assigned commit_version are "remote" in nature. I don't think this name helps disambiguate (if it's intended to).

The Firestore RPC API exposes the following versions:
https://github.com/googleapis/googleapis/blob/468e07f7a9eaa3ecb4fac719bbd28745a5485cc4/google/firestore/v1beta1/firestore.proto#L39

It seems to me that we should not introduce new names with new meanings unless we're specifically diverging from these names. If we are diverging we should add fairly extensive comments about how this is supposed to fit together.

Based on your changes to the serializer, this seems like mostly the update_time but sometimes the read_time.

Would it make sense to remove remoteVersion here and instead keep updatedVersion in Document and readVersion in NoDocument?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

For documents, we need to be able to tell the commitTime apart from udpateTime. Currently, one version is stored inside api.Document and serialized as part of the document, while the other is serialized in a separate field.

We can just store a single version and add dirty boolean. We could then go back and name the one version that we have version and make it the readTime for NoDocument and the last modified time for Documents (which is either the commitTime or the updateTime).

What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

FYI, I pushed a commit that replaces the two different versions with a "dirty" boolean that I named hasCommittedMutations.

@schmidt-sebastian
Copy link
Contributor Author

PR updated with the result of our discussion:

  • We store a "dirty" bit in IndexedDb.
  • We store "unknown documents" in IndexedDb. These are documents that are known to exist at a specific version, but have no data. This allows us to simply some of our View processing logic.
  • We store deleted documents at the time of their delete.

Copy link
Contributor

@wilhuff wilhuff left a comment

Choose a reason for hiding this comment

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

Mostly LGTM

@@ -425,6 +425,14 @@ export class DbNoDocument {
constructor(public path: string[], public readTime: DbTimestamp) {}
}

/**
* Represents a document that is know to exist but whose data is unknwon.
Copy link
Contributor

Choose a reason for hiding this comment

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

s/know to exist/known to exist/
s/unknwon/unknown/

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed.

@@ -437,6 +445,12 @@ export class DbRemoteDocument {
static store = 'remoteDocuments';

constructor(
/**
* Set to an instance of DbUnknownDocument if the data for a document is
* not known, but it is known that a document exist at the specified version
Copy link
Contributor

Choose a reason for hiding this comment

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

s/exist at/exists at/

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed.

export class DbUnknownDocument {
constructor(public path: string[], public readTime: DbTimestamp) {}
}

/**
* An object to be stored in the 'remoteDocuments' store in IndexedDb. It
* represents either a cached document (if it exists) or a cached "no-document"
Copy link
Contributor

Choose a reason for hiding this comment

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

This comment could do with updating. Something like:

An object to be stored in the 'remoteDocuments' store in IndexedDb. It represents one of three things:

  • A complete document
  • A "no document" representing a document that is known not to exist (at some version)
  • An "unknown document" representing a document that is known to exist (at some version) but whose contents are unknown.

* documents are potentially inconsistent with the document's version on
* the backend.
*/
public hasCommittedMutations: boolean | undefined
Copy link
Contributor

Choose a reason for hiding this comment

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

We keep calling this a "dirty" bit when discussing it and I don't think this name is that much clearer for all its extra verbosity.

The comment above could also indicate that this has an impact on how to interpret the version stored inside the different document types.

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 updated the commit.

I opted to stick with hasCommittedMutations for now. It is the same name that I use in Document, and I chose that name for two reasons:

  • It works well in combination with hasLocalMutations and explains the either-or state that these two states convey. Only one of hasLocalMutations or hasCommittedMutations can be true at any given time.

  • Similarly, calling this dirty poses the question of whether documents with hasLocalMutations are dirty. If the answer to that is a resounding yes, that would mean we have to update all tests that use hasLocalMutations to also set dirty.

Copy link
Contributor

Choose a reason for hiding this comment

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

I can see that. Agreed.

@@ -110,9 +127,39 @@ export class NoDocument extends MaybeDocument {
return `NoDocument(${this.key}, ${this.version})`;
}

isEqual(other: NoDocument): boolean {
hasPendingWrites(): boolean {
return false;
Copy link
Contributor

Choose a reason for hiding this comment

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

If we have a committed delete with an acknowledged version greater than the watch snapshot version, wouldn't we want that to be true here?

If not, could you add comments here about why this is necessarily false?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In an ideal world, we would want this to be true. We however don't support hasPendingWrites on deletes (In master, we only ever used Document.hasLocalMutations). If we want to change this behavior, then I would like to do this later.

Comment added.

@@ -220,7 +226,7 @@ export abstract class Mutation {
abstract applyToRemoteDocument(
maybeDoc: MaybeDocument | null,
mutationResult: MutationResult
): MaybeDocument | null;
): MaybeDocument;
Copy link
Contributor

Choose a reason for hiding this comment

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

The comment above indicates that this can still return null.

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 updated this comment as well as the broader class comment.

@@ -377,13 +383,12 @@ export class PatchMutation extends Mutation {
// patch, so we use the precondition to prevent incorrectly putting a
// partial document into our cache.
if (!this.precondition.isValidFor(maybeDoc)) {
return maybeDoc;
return new UnknownDocument(this.key, mutationResult.version);
Copy link
Contributor

Choose a reason for hiding this comment

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

This doesn't seem right, or at least not intuitively so, based on how we've documented this type. This needs a comment that describes why this is an unknown document. I thought we should preserve null until we discussed offline, so maybe something like:

If we're here applying a mutation to a remote document, the server must not have rejected the write, the precondition must have held, so the document must actually exist.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added the following comment:

      // Since the mutation was not rejected, we know that the  precondition
      // matched on the backend. We therefore must not have the expected version
      // of the document in our cache and return an UnknownDocument with the
      // known updateTime.

@@ -377,13 +383,12 @@ export class PatchMutation extends Mutation {
// patch, so we use the precondition to prevent incorrectly putting a
Copy link
Contributor

Choose a reason for hiding this comment

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

You can axe this TODO here. This change pretty much addresses it.

@@ -484,17 +489,19 @@ export class TransformMutation extends Mutation {
// patch, so we use the precondition to prevent incorrectly putting a
// partial document into our cache.
if (!this.precondition.isValidFor(maybeDoc)) {
return maybeDoc;
return new UnknownDocument(this.key, mutationResult.version);
Copy link
Contributor

Choose a reason for hiding this comment

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

Same comments as above (axe the TODO and describe why this document must actually exist).

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 this.serializer.fromWriteResults(response.writeResults);
return this.serializer.fromWriteResults(
response.writeResults,
response.commitTime
Copy link
Contributor

Choose a reason for hiding this comment

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

Does this need a bang for strict null checks? (i.e. response.commitTime!)

I ask because the assignment to const commitVersion uses one below.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

fromWriteResults takes an optional commit time and changes it to a non-optional commitTime. Empty WriteBatches return no commit time, and hence we have to check that we have at least one write response before we can use the commit time.

Copy link
Contributor Author

@schmidt-sebastian schmidt-sebastian left a comment

Choose a reason for hiding this comment

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

Comments addressed

@@ -425,6 +425,14 @@ export class DbNoDocument {
constructor(public path: string[], public readTime: DbTimestamp) {}
}

/**
* Represents a document that is know to exist but whose data is unknwon.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed.

@@ -437,6 +445,12 @@ export class DbRemoteDocument {
static store = 'remoteDocuments';

constructor(
/**
* Set to an instance of DbUnknownDocument if the data for a document is
* not known, but it is known that a document exist at the specified version
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed.

* documents are potentially inconsistent with the document's version on
* the backend.
*/
public hasCommittedMutations: boolean | undefined
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 updated the commit.

I opted to stick with hasCommittedMutations for now. It is the same name that I use in Document, and I chose that name for two reasons:

  • It works well in combination with hasLocalMutations and explains the either-or state that these two states convey. Only one of hasLocalMutations or hasCommittedMutations can be true at any given time.

  • Similarly, calling this dirty poses the question of whether documents with hasLocalMutations are dirty. If the answer to that is a resounding yes, that would mean we have to update all tests that use hasLocalMutations to also set dirty.

@@ -110,9 +127,39 @@ export class NoDocument extends MaybeDocument {
return `NoDocument(${this.key}, ${this.version})`;
}

isEqual(other: NoDocument): boolean {
hasPendingWrites(): boolean {
return false;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

In an ideal world, we would want this to be true. We however don't support hasPendingWrites on deletes (In master, we only ever used Document.hasLocalMutations). If we want to change this behavior, then I would like to do this later.

Comment added.

@@ -79,9 +84,10 @@ export class FieldTransform {
export class MutationResult {
constructor(
/**
* The version at which the mutation was committed or null for a delete.
* The version at which the mutation was committed, either extracted from
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated the comment to use your suggested phrasing.

@@ -220,7 +226,7 @@ export abstract class Mutation {
abstract applyToRemoteDocument(
maybeDoc: MaybeDocument | null,
mutationResult: MutationResult
): MaybeDocument | null;
): MaybeDocument;
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 updated this comment as well as the broader class comment.

@@ -377,13 +383,12 @@ export class PatchMutation extends Mutation {
// patch, so we use the precondition to prevent incorrectly putting a
// partial document into our cache.
if (!this.precondition.isValidFor(maybeDoc)) {
return maybeDoc;
return new UnknownDocument(this.key, mutationResult.version);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added the following comment:

      // Since the mutation was not rejected, we know that the  precondition
      // matched on the backend. We therefore must not have the expected version
      // of the document in our cache and return an UnknownDocument with the
      // known updateTime.

@@ -484,17 +489,19 @@ export class TransformMutation extends Mutation {
// patch, so we use the precondition to prevent incorrectly putting a
// partial document into our cache.
if (!this.precondition.isValidFor(maybeDoc)) {
return maybeDoc;
return new UnknownDocument(this.key, mutationResult.version);
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 this.serializer.fromWriteResults(response.writeResults);
return this.serializer.fromWriteResults(
response.writeResults,
response.commitTime
Copy link
Contributor Author

Choose a reason for hiding this comment

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

fromWriteResults takes an optional commit time and changes it to a non-optional commitTime. Empty WriteBatches return no commit time, and hence we have to check that we have at least one write response before we can use the commit time.

Copy link
Contributor

@wilhuff wilhuff left a comment

Choose a reason for hiding this comment

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

LGTM, except I'm not sure this can go into master as is.

Specifically:

  • Don't we need a migration to convert acknowledged mutations still in the mutation queue to documents with hasCommittedChanges true?
  • Don't we need to change some of the code that handles NoDocument specially? For example: Precondition.exists needs to account for this new kind of document.

Happy to take these as follow-on PRs

* documents are potentially inconsistent with the document's version on
* the backend.
*/
public hasCommittedMutations: boolean | undefined
Copy link
Contributor

Choose a reason for hiding this comment

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

I can see that. Agreed.

* have no explicit update time, we use the commitTime of the WriteResponse for
* Delete mutations.
*
* If due to precondition mismatches an acknowledged mutation can't be applied
Copy link
Contributor

Choose a reason for hiding this comment

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

This language is hard to understand. How about:

If a mutation is acknowledged but locally fails its precondition, we return an UnknownDocument and rely on Watch to send us the updated version.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Simplified to:

 * If a mutation is acknowledged by the backend but fails the precondition check
 * locally, we return an `UnknownDocument` and rely on Watch to send us the
 * updated version.

@@ -315,8 +312,12 @@ function genericLocalStoreTests(
)
.toContain(doc('foo/bar', 0, { foo: 'bar' }, { hasLocalMutations: true }))
.afterAcknowledgingMutation({ documentVersion: 1 })
.toReturnChanged(doc('foo/bar', 0, { foo: 'bar' }))
.toContain(doc('foo/bar', 0, { foo: 'bar' }))
.toReturnChanged(
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice!

assertVersionTransitions(set, docV3, mutationResult, docV3);
assertVersionTransitions(set, deletedV3, mutationResult, docV0);
assertVersionTransitions(set, null, mutationResult, docV0);
assertVersionTransitions(set, docV3, mutationResult, docV7Committed);
Copy link
Contributor

Choose a reason for hiding this comment

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

This test essentially implements the version transitions in that big comment on top of Mutation. Could you update the comment? Alternatively, if you didn't find the table helpful, maybe we we should just delete it?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The test invokes applyToRemoteDocument, which has changed a lot and hence the changes to the test. The table in the comment is for applyToLocalView, which should still be the same.

@schmidt-sebastian
Copy link
Contributor Author

Specifically:

Don't we need a migration to convert acknowledged mutations still in the mutation queue to documents with hasCommittedChanges true?

I don't think so. They will get replayed as before and create Documents with hasLocalMutatations. With this PR, we now have two codepaths that do the same thing.

Don't we need to change some of the code that handles NoDocument specially? For example: Precondition.exists needs to account for this new kind of document.

I will look at that.

In general, I wasn't going to merge this PR until I know that these changes work with my main held-write-acks-removal branch.

@wilhuff wilhuff assigned schmidt-sebastian and unassigned wilhuff Aug 29, 2018
@schmidt-sebastian schmidt-sebastian changed the title Adding CommitVersion to RemoteDocumentCache Remove Held Write Acks Sep 5, 2018
@schmidt-sebastian schmidt-sebastian merged commit ad2d178 into master Sep 5, 2018
@schmidt-sebastian schmidt-sebastian deleted the mrschmidt-commitversion branch September 5, 2018 18:22
@firebase firebase locked and limited conversation to collaborators Oct 16, 2019
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants