-
Notifications
You must be signed in to change notification settings - Fork 945
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
Remove Held Write Acks #1156
Conversation
05fe956
to
9a981b1
Compare
constructor(readonly key: DocumentKey, readonly version: SnapshotVersion) {} | ||
constructor( | ||
readonly key: DocumentKey, | ||
readonly remoteVersion: SnapshotVersion |
There was a problem hiding this comment.
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
?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
.
PR updated with the result of our discussion:
|
There was a problem hiding this 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. |
There was a problem hiding this comment.
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/
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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/
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 ofhasLocalMutations
orhasCommittedMutations
can be true at any given time. -
Similarly, calling this
dirty
poses the question of whether documents withhasLocalMutations
aredirty
. If the answer to that is a resounding yes, that would mean we have to update all tests that usehasLocalMutations
to also setdirty
.
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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
.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this 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. |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 ofhasLocalMutations
orhasCommittedMutations
can be true at any given time. -
Similarly, calling this
dirty
poses the question of whether documents withhasLocalMutations
aredirty
. If the answer to that is a resounding yes, that would mean we have to update all tests that usehasLocalMutations
to also setdirty
.
@@ -110,9 +127,39 @@ export class NoDocument extends MaybeDocument { | |||
return `NoDocument(${this.key}, ${this.version})`; | |||
} | |||
|
|||
isEqual(other: NoDocument): boolean { | |||
hasPendingWrites(): boolean { | |||
return false; |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this 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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
I don't think so. They will get replayed as before and create Documents with
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. |
* Checking for UnknownDocument * Remove unused import * Adding an assert in Transaction
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 raisehasPendingWrites
. Only mutations that are acknowledged during the lifetime of a query raisehasPendingWrites
. 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.