Skip to content

Remove Held Write Acks #1135

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

Conversation

schmidt-sebastian
Copy link
Contributor

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

This PR removes held write acks, while mostly maintaining the existing user-facing properties. Instead of keeping mutations in the mutation queue, they are directly applied to the RemoteDocumentCache. As part of this, the cache entry now tracks the "commit version" and we can thus tell whether a document is dirty.

If a document is mutated after query initialization time (the first SnapshotVersion received from the backend), we raise hasPendingWrites. For documents that were modified before the query was registered, we raise isFromCache (this is meant to mirror the previous behavior, as we cleared write acks for no listen).

There is special logic to that deals with patches to unknown documents. Updates to these documents get filtered in the view until Watch catches up.

This is a large PR that rewrites some of our core view handling, as as such, it is likely beneficial to split this up. I am sending this out in one big chunk for now to get some high level feedback. Once that has been addressed, I plan to split this PR up.

Possible splitting points:

  • Extract the schema migration that drops held write acks. This will also include the plumbing of DatabaseId to createOrUpgradeDb.
  • Changing of removeMutationBatches to only accept a single batch and dropping of getHighestAcknowledgedBatchID.
  • Addition of SnapshotVersion to SharedClientState (used to update the Views of secondary tabs)
  • The rest of the PR, including the new and updated Spec tests.

There are also some more unit tests that I should add for the View changes.

* of this view. Any document edited after will have `hasPendingWrites` set
* until Watch catches up.
*/
private firstSnapshotVersion = SnapshotVersion.MIN;
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 doesn't make sense to me. "first snapshot version that watch has sent" seems like an immutable property, but then "any document edited after will have hasPendingWrites" suggests that either we'll never clear hasPendingWrites or this value will actually change as Watch gives us updates.

Also "edited" is imprecise, are you referring to our having something in the mutation queue or are you referring to our receiving a write acknowledgement?

Finally, this pair of names suggest a range but I don't understand what the range actually means and the comments here don't specify how to treat these as a range. Perhaps suggesting these are related in this way isn't helpful?

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 removed these snapshot versions:

  • Since we store UnknownDocuments in the database, we no longer need to check whether the snapshots "went back in time".
  • I added a special computeInitialChanges() method that gets rid of the need for firstSnapshotVersion. This method differs from computeDocChanges in that ignores hasCommittedMutations for the first view snapshot.

* ignore events if the Write and the Watch stream send us update out of
* order.
*/
private lastSnapshotVersion = SnapshotVersion.MIN;
Copy link
Contributor

Choose a reason for hiding this comment

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

"view has been synced with" is imprecise: is this a watch snapshot version, a write acknowledgement version?

Basically: the protocol specifies versions in various places but I don't understand how those versions map onto these versions. I think this would be clearer if the definitions of these values were less squishy and more directly corresponded to values we already understand and then use derived properties to drive the logic.

For example, If I'm understanding correctly, I think this is describing the maximum of the latest watch snapshot version and the write acknowledgement version. Could we just store watchSnapshotVersion and commitVersion, and define a highestVersion that returns the max of them?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This comment should have been resolved by the changes in this and the earlier PRs.

@@ -105,12 +125,15 @@ export class View {
* need to go back to the local cache for more results. Does not make any
* changes to the view.
* @param docChanges The doc changes to apply to this view.
* @param snapshotVersion The snapshot version that triggered the doc changes
Copy link
Contributor

Choose a reason for hiding this comment

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

The comments suggest that firstSnapshotVersion is the first version from watch, but nothing about this signature requires that to be the case. Is there any way to plumb this such that this version only comes from a remote event (thus guaranteeing that it actually comes from watch)?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This argument has been removed.

// If a document update isn't authoritative, make sure we don't
// apply an old document version to the remote cache. We make an
// exception for SnapshotVersion.MIN which can happen for
// manufactured events (e.g. in the case of a limbo document
// resolution failing).

if (authoritativeUpdates.has(doc.key)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

This seems to have rephrased the previous version without any real change (i.e. what was wrong with all of these phrases being ||ed together?

Secondarily this seems complicated enough that we should pull it out into a separate method. The caller is getting big enough that we should push heavily to move complicated code out to keep this readable.

Finally, these conditions seem to overlap in ways that could make the cases easier to understand if we combined these into larger branches.

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 wanted to be able to flip shouldApplyUpdate back to false, and that's how I ended up changing these statements.

In the end, most of these changes are no longer needed so I reverted almost all of this.

new NoDocument(key, SnapshotVersion.forDeletedDoc())
new NoDocument(
key,
SnapshotVersion.forDeletedDoc(),
Copy link
Contributor

Choose a reason for hiding this comment

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

This seems confusing that we need two versions here and that they would use two different names for the same value.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

All documents now only have one version.

name: string,
version: number,
runUpgrade: (
db: IDBDatabase,
databaseId: DatabaseId,
Copy link
Contributor

Choose a reason for hiding this comment

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

This seems unused?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This was used in the serializer. Addressed in earlier PR.

@@ -89,21 +109,26 @@ export class Document {
* denotes time we know it didn't exist at.
*/
export class NoDocument {
constructor(readonly key: DocumentKey, readonly version: SnapshotVersion) {}
constructor(
Copy link
Contributor

Choose a reason for hiding this comment

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

These versions aren't really used in here. Also there's no hasPendingWrites defined for this type, which seems confusing.

Is it time to make MaybeDocument a base class rather than a type union?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This has been resolved in an earlier PR.

@@ -51,19 +52,38 @@ export class Document {
return this.data.value();
}

hasPendingWrites(baseSnapshotVersion: SnapshotVersion): boolean {
Copy link
Contributor

Choose a reason for hiding this comment

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

Needs comments.

I don't intrinsically understand how "has pending writes" relates to a snapshot version. Is the snapshot version a version from watch? A commit version? The name "baseSnapshotVersion" doesn't help me understand this either. Is the base version coming from watch? From storage?

Also, while this definitely operates on properties of a document it seems like this notion of hasPendingWrites is something that is much more closely related to the algorithms of whether or not we accept an update, and would likely be better off kept with that code. As it stands, that logic is now sort of weirdly split up, and it's not like documents intrinsically have this property in any way that could be expressed universally.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is much simpler now: A Document has pending writes when either localMutations or committedMutations are present. A NoDocument has pending writes when committedMutations (note that this is internal only and not exposed to the user).

// If the document was committed after Watch has delivered the base
// snapshot, we raise `hasPendingWrites` as long as the local commit version
// remains higher than the version sent to us by Watch.
if (
Copy link
Contributor

Choose a reason for hiding this comment

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

As written this seems like it could just be:

return !baseSnapshotVersion.isEqual(SnapshotVersion.MIN) &&
    this.commitVersion.compareTo(baseSnapshotVersion) >= 0 &&
    this.commitVersion.compareTo(this.remoteVersion) > 0;

However, I don't understand why the commitVersion > remoteVersion has anything to do with baseSnapshotVersion. If the commit version is greater than the remote version, why does the baseSnapshotVersion matter at all?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No longer applicable.

@@ -173,6 +176,7 @@ export interface SharedClientState {
* encoded as part of the key.
*/
interface MutationMetadataSchema {
snapshotVersionMicros: number;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We should store this as seconds/nanos.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No longer applicable since we don't need to look these version up from LocalStorage.

@schmidt-sebastian schmidt-sebastian changed the base branch from master to mrschmidt-commitversion August 30, 2018 21:05
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.

Addressed all comments and resolved all issues that were raised within the last two weeks. Back to you, Gil.

* of this view. Any document edited after will have `hasPendingWrites` set
* until Watch catches up.
*/
private firstSnapshotVersion = SnapshotVersion.MIN;
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 removed these snapshot versions:

  • Since we store UnknownDocuments in the database, we no longer need to check whether the snapshots "went back in time".
  • I added a special computeInitialChanges() method that gets rid of the need for firstSnapshotVersion. This method differs from computeDocChanges in that ignores hasCommittedMutations for the first view snapshot.

* ignore events if the Write and the Watch stream send us update out of
* order.
*/
private lastSnapshotVersion = SnapshotVersion.MIN;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This comment should have been resolved by the changes in this and the earlier PRs.

@@ -105,12 +125,15 @@ export class View {
* need to go back to the local cache for more results. Does not make any
* changes to the view.
* @param docChanges The doc changes to apply to this view.
* @param snapshotVersion The snapshot version that triggered the doc changes
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This argument has been removed.

// If a document update isn't authoritative, make sure we don't
// apply an old document version to the remote cache. We make an
// exception for SnapshotVersion.MIN which can happen for
// manufactured events (e.g. in the case of a limbo document
// resolution failing).

if (authoritativeUpdates.has(doc.key)) {
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 wanted to be able to flip shouldApplyUpdate back to false, and that's how I ended up changing these statements.

In the end, most of these changes are no longer needed so I reverted almost all of this.

new NoDocument(key, SnapshotVersion.forDeletedDoc())
new NoDocument(
key,
SnapshotVersion.forDeletedDoc(),
Copy link
Contributor Author

Choose a reason for hiding this comment

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

All documents now only have one version.

@@ -173,6 +176,7 @@ export interface SharedClientState {
* encoded as part of the key.
*/
interface MutationMetadataSchema {
snapshotVersionMicros: number;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

No longer applicable since we don't need to look these version up from LocalStorage.

name: string,
version: number,
runUpgrade: (
db: IDBDatabase,
databaseId: DatabaseId,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This was used in the serializer. Addressed in earlier PR.

@@ -51,19 +52,38 @@ export class Document {
return this.data.value();
}

hasPendingWrites(baseSnapshotVersion: SnapshotVersion): boolean {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is much simpler now: A Document has pending writes when either localMutations or committedMutations are present. A NoDocument has pending writes when committedMutations (note that this is internal only and not exposed to the user).

// If the document was committed after Watch has delivered the base
// snapshot, we raise `hasPendingWrites` as long as the local commit version
// remains higher than the version sent to us by Watch.
if (
Copy link
Contributor Author

Choose a reason for hiding this comment

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

No longer applicable.

@@ -89,21 +109,26 @@ export class Document {
* denotes time we know it didn't exist at.
*/
export class NoDocument {
constructor(readonly key: DocumentKey, readonly version: SnapshotVersion) {}
constructor(
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This has been resolved in an earlier PR.

@@ -36,7 +37,7 @@ export class Transaction {

constructor(private datastore: Datastore) {}

private recordVersion(doc: MaybeDocument): void {
private recordVersion(doc: NoDocument | Document): void {
Copy link
Contributor

Choose a reason for hiding this comment

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

This construct is non-portable. Might as well take MaybeDocument and assert this is not an UnknownDocument (which is a portable construct).

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 already changed this when I did the initial Android port. This function takes a MaybeDocument and has an assert inside of it. The same is now true for the Web.

@@ -87,7 +95,7 @@ export function query(path: string): Query {
* document, with the key being the document id, and the value being the document contents.
* @param docsToAdd Specifies data to be added into the query snapshot as of now. Each entry maps
* to a document, with the key being the document id, and the value being the document contents.
* @param hasPendingWrites Whether the query snapshot has pending writes.
* @param mutatedKes The list of document with pending writes.
Copy link
Contributor

Choose a reason for hiding this comment

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

mutatedKeys?

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.

* @return A new set of docs, changes, and refill flag.
*/
computeInitialChanges(docs: MaybeDocumentMap): ViewDocumentChanges {
// TODO: Call this directly from the constructor and use
Copy link
Contributor

Choose a reason for hiding this comment

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

Use TODO(name):, though doing this kind of work in the constructor seems like a bad idea, so perhaps just strike the TODO?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Removed the TODO for now. The initial setup to get both a ViewSnapshot and a View involves a lot of back-and-forth of the same data, and I was leaning towards reducing this. I'll think some more about this after all these PRs are merged.

// That means there may be some doc in the local cache that's
// actually less than this one.
needsRefill = true;
if (
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this functional change intended? Previously we'd check the limit case in both the full and metadata-only cases but now only in the former case.

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's simpler now to do this check only in this branch (since we otherwise also have to include changeApplied in the if-check). I can't think of a case where a document would fall outside the limit based on a metadata-only change.

// instead of three (one with `hasPendingWrites`, the modified
// document with `hasPendingWrites` and the final state of the
// document).
return (
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 appear to actually wait for a version (just the local-mutations/committed-mutations properties). That suggests that either this name is wrong (since it suggests a version comparison) or this is missing something critical.

Incidentally where is the version check?

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 used to have a version check in here when I still dealt with versions in the view. Now that I have the UnknownDocument, I no longer need to look at the document version, but only on the committed flag. If I know that a document had mutations committed, then I want to wait for the acknowledgement from Watch.

BTW, this essentially ignores all documents updated in a WriteAck and we could look at handling this outside the View.

I renamed this method to shouldWaitForSyncedDocument. I debated naming 'synced' 'remote' but that term is a little bit overloaded already in our SDK.

@@ -56,7 +56,9 @@ export class LocalSerializer {
} else if (remoteDoc.noDocument) {
const key = DocumentKey.fromSegments(remoteDoc.noDocument.path);
const version = this.fromDbTimestamp(remoteDoc.noDocument.readTime);
return new NoDocument(key, version);
return new NoDocument(key, version, {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: it's a bit jarring to see NoDocument taking a keyword argument but DbRemoteDocument with a DbNoDocument taking regular positional parameters. Could this be done consistently?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

DbRemoteDocument is the persisted version of the document. I felt that it was saner to directly encode the boolean flag there, especially since the schema is already in JSON and has the name encoded.

DbNoDocument looks this way to match DbDocument.

@@ -80,32 +80,39 @@ describe('Query', () => {
describe('QuerySnapshot', () => {
it('support equality checking with isEqual()', () => {
expectEqual(
querySnapshot('foo', {}, { a: { a: 1 } }, true, false, false),
querySnapshot('foo', {}, { a: { a: 1 } }, true, false, false)
querySnapshot('foo', {}, { a: { a: 1 } }, keys('coll/a'), false, false),
Copy link
Contributor

Choose a reason for hiding this comment

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

"coll/a" seems to be unrelated to the collection in question.

Also, it's weird that this can have mutated keys but neither from cache nor sync state changed flags set.

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 changed these tests to use foo.

I also went through and removed the mutations from most of these comparisons. Only the one case that directly compared the mutated keys still has mutated keys with both flags false (to make it obvious what is tested here).
BTW, I think this case could happen, albeit it would be rare (the backend has sent us all committed documents, but we are still not CURRENT since there are more edits from other clients).

I also fixed the ViewSnapshot comparison to take mutatedKeys into account.

@wilhuff wilhuff assigned schmidt-sebastian and unassigned wilhuff Sep 5, 2018
@schmidt-sebastian schmidt-sebastian removed their assignment Sep 5, 2018
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

@schmidt-sebastian schmidt-sebastian merged commit 71d9fbc into mrschmidt-commitversion Sep 5, 2018
@schmidt-sebastian schmidt-sebastian deleted the mrschmidt-writeacks-v3 branch September 5, 2018 18:22
@firebase firebase locked and limited conversation to collaborators Oct 17, 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