Skip to content

Don't crash if Target cannot be persisted #2944

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 4 commits into from
May 1, 2020

Conversation

schmidt-sebastian
Copy link
Contributor

@schmidt-sebastian schmidt-sebastian commented Apr 20, 2020

This PR rejects a Query listener if its Target cannot be allocated.

Addresses #2755

@google-oss-bot
Copy link
Contributor

google-oss-bot commented Apr 20, 2020

Binary Size Report

Affected SDKs

  • @firebase/firestore

    Type Base (e03614c) Head (99b7441) Diff
    browser 247 kB 247 kB +324 B (+0.1%)
    esm2017 193 kB 193 kB +112 B (+0.1%)
    main 487 kB 488 kB +379 B (+0.1%)
    module 245 kB 245 kB +302 B (+0.1%)
  • @firebase/firestore/memory

    Type Base (e03614c) Head (99b7441) Diff
    browser 188 kB 188 kB +324 B (+0.2%)
    esm2017 148 kB 148 kB +112 B (+0.1%)
    main 364 kB 365 kB +379 B (+0.1%)
    module 186 kB 187 kB +302 B (+0.2%)
  • firebase

    Type Base (e03614c) Head (99b7441) Diff
    firebase-firestore.js 289 kB 289 kB +304 B (+0.1%)
    firebase-firestore.memory.js 231 kB 231 kB +304 B (+0.1%)
    firebase.js 822 kB 823 kB +306 B (+0.0%)

Test Logs

@schmidt-sebastian schmidt-sebastian requested review from wilhuff and removed request for wilhuff April 20, 2020 16:22
@@ -59,15 +67,14 @@ export class EventManager implements SyncEngineListener {
this.syncEngine.subscribe(this);
}

listen(listener: QueryListener): Promise<TargetId> {
async listen(listener: QueryListener): Promise<void> {
Copy link
Contributor Author

@schmidt-sebastian schmidt-sebastian Apr 20, 2020

Choose a reason for hiding this comment

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

I removed the return value since a failed target doesn't have an ID. The only consumer of the targetId result were the spec tests, which already verify the target ID in every step as part of expectedActiveTargets.

Copy link
Contributor

Choose a reason for hiding this comment

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

Why doesn't failing to listen reject the promise here? If it did then you could return the TargetId in the successful 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.

If I reject the Promise here, then FirestoreClient has to handle the rejected Promise. This is actually pretty nice and keeps the code in EventManager much cleaner. The calling code is also still clean, since it was simple to begin with.

The problem I see is that this makes this code non-testable. The spec tests don't use FirestoreClient, and the integration cannot inject failures. I have made the change regardless and added some special handling in SyncEngine. I am not sure if this is an improvement though.

Copy link
Contributor

Choose a reason for hiding this comment

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

I see what you mean; the spec test has to emulate this for itself. That's not completely awful but you're probably right that the other way was better because it was testable.

Would it help to put the error handling in SyncEngine?

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 think SyncEngine is not the right place for this since it can only convey two states: Success or failure. We really need three states: Success, failure (but don't crash AsyncQueue) and failure (crash AsyncQueue).
Unless we change SyncEngine.listen() return a tristate, we have to wrap any error thrown by SyncEngine in the same if (error.name === '...') check when we invoke the QueryListener.

Given this and the test ability, I moved the code back to EventListener. It's not as clean looking, but it will probably be easier to keep regression free.

@@ -246,8 +252,7 @@ export class SyncEngine implements RemoteSyncer, SharedClientStateSyncer {
}
}

this.syncEngineListener!.onWatchChange([viewSnapshot]);
Copy link
Contributor Author

@schmidt-sebastian schmidt-sebastian Apr 20, 2020

Choose a reason for hiding this comment

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

I had to remove this indirection. onWatchChange doesn't fire if EventManager doesn't execute this.queries.set(query, queryInfo) first.

Copy link
Contributor

Choose a reason for hiding this comment

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

At first I was concerned about this because it seemed like this would prevent calling onViewSnapshot on the listener, but actually it looks like code to do that was already explicitly in the EventManager (in if (queryInfo.viewSnap), and I'm guessing that the explicit code was just being treated as a no-op because previously queryInfo.viewSnap would be undefined before?

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 am not sure I fully understand this, but I hope that my previous comment reliefs your concerns. Let me know if it doesn't.

@@ -98,6 +98,12 @@ class QueryView {
) {}
}

/** Result type returned during initial query registration. */
export interface QueryRegistration {
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 now have three different versions of this interface: QueryRegistration. ViewChange, QueryListenersInfo

They are all a bit different and i don't see a way to combine them. ViewChange would be the best candidate, but the View does not expose the targetId.

Copy link
Contributor

Choose a reason for hiding this comment

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

The old arrangement where the sync engine was responsible for the first snapshot avoided this problem.

I wonder if would be possible for the sync engine to call back indicating the listen failed instead? That way EventManager.listen could proceed optimistically and then just surface the error/remove the listener as normal without any changes.

Copy link
Contributor Author

@schmidt-sebastian schmidt-sebastian Apr 23, 2020

Choose a reason for hiding this comment

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

The issue here is actually the success case - the problem here is the ViewSnapshot for the initial listen needs to be raised. The current flow is:

  • EventManager's listen() registers target in local data structure
  • EventManager calls SyncEngine listen()
  • SyncEngine's listen() calls back EventManager
  • EventManager looks in local data structure to raise snapshot
  • SyncEngine finishes listen()
  • EventManager finishes listen()

This code seems fragile to me The callback in SyncEngine relies on an implementation detail of EventManager. I was very surprised that re-arranging the internal code of EventManager broke tests that were unrelated to my change. Because of this, I think it is better to return a ViewSnapshot.

Having said that, I was able to remove this type after all because EventManager doesn't actually use the target ID (other than to pass it through). I updated listen() to just return the initial snapshot.

@schmidt-sebastian schmidt-sebastian changed the base branch from mrschmidt/limitretries to master April 21, 2020 01:41
@@ -1,4 +1,6 @@
# Unreleased
- [fixed] Firestore now rejects `onSnapshot()` listeners if they cannot be
Copy link
Contributor

Choose a reason for hiding this comment

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

This and the next release note could probably be combined. Something like:

Firestore now rejects onSnapshot listeners are writes if they cannot be persisted in IndexedDB. ...

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 prior change is going out this week. Unfortunately, this Changelog is not all that clean, but this is mostly due to the fact the version numbers are impossible to figure out. Maybe we can discuss this with the team and clean it up once and for all.

@@ -59,15 +67,14 @@ export class EventManager implements SyncEngineListener {
this.syncEngine.subscribe(this);
}

listen(listener: QueryListener): Promise<TargetId> {
async listen(listener: QueryListener): Promise<void> {
Copy link
Contributor

Choose a reason for hiding this comment

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

Why doesn't failing to listen reject the promise here? If it did then you could return the TargetId in the successful case.

queryInfo.targetId = targetId;
queryInfo.viewSnap = snapshot;
} catch (e) {
if (e.name === 'IndexedDbTransactionError') {
Copy link
Contributor

Choose a reason for hiding this comment

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

This may not be possible, but it seems like could benefit from handling these errors consistently in our API.

For example, we try to make FirestoreClient the place where we centrally handle dispatching onto the AsyncQueue. Should we try to similarly make handling retries (or error handling like this) a part of the SyncEngine?

This doesn't seem to work in this case because you're returning a successful promise in case of a known error, but that seems to be catering to the caller's use of this function in a way that makes this more complicated than it needs to be. Maybe the caller should handle this in the block submitted to the async queue?

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 did end up updating this, but as stated this pushes the error handling to both FirestoreClient and SpecTestRunner.

@@ -246,8 +252,7 @@ export class SyncEngine implements RemoteSyncer, SharedClientStateSyncer {
}
}

this.syncEngineListener!.onWatchChange([viewSnapshot]);
Copy link
Contributor

Choose a reason for hiding this comment

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

At first I was concerned about this because it seemed like this would prevent calling onViewSnapshot on the listener, but actually it looks like code to do that was already explicitly in the EventManager (in if (queryInfo.viewSnap), and I'm guessing that the explicit code was just being treated as a no-op because previously queryInfo.viewSnap would be undefined before?

@@ -98,6 +98,12 @@ class QueryView {
) {}
}

/** Result type returned during initial query registration. */
export interface QueryRegistration {
Copy link
Contributor

Choose a reason for hiding this comment

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

The old arrangement where the sync engine was responsible for the first snapshot avoided this problem.

I wonder if would be possible for the sync engine to call back indicating the listen failed instead? That way EventManager.listen could proceed optimistically and then just surface the error/remove the listener as normal without any changes.

@wilhuff wilhuff assigned schmidt-sebastian and unassigned wilhuff Apr 22, 2020
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.

Thanks for the review. I have another controversial version for you :)

@@ -1,4 +1,6 @@
# Unreleased
- [fixed] Firestore now rejects `onSnapshot()` listeners if they cannot be
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 prior change is going out this week. Unfortunately, this Changelog is not all that clean, but this is mostly due to the fact the version numbers are impossible to figure out. Maybe we can discuss this with the team and clean it up once and for all.

@@ -59,15 +67,14 @@ export class EventManager implements SyncEngineListener {
this.syncEngine.subscribe(this);
}

listen(listener: QueryListener): Promise<TargetId> {
async listen(listener: QueryListener): Promise<void> {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

If I reject the Promise here, then FirestoreClient has to handle the rejected Promise. This is actually pretty nice and keeps the code in EventManager much cleaner. The calling code is also still clean, since it was simple to begin with.

The problem I see is that this makes this code non-testable. The spec tests don't use FirestoreClient, and the integration cannot inject failures. I have made the change regardless and added some special handling in SyncEngine. I am not sure if this is an improvement though.

queryInfo.targetId = targetId;
queryInfo.viewSnap = snapshot;
} catch (e) {
if (e.name === 'IndexedDbTransactionError') {
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 did end up updating this, but as stated this pushes the error handling to both FirestoreClient and SpecTestRunner.

@@ -98,6 +98,12 @@ class QueryView {
) {}
}

/** Result type returned during initial query registration. */
export interface QueryRegistration {
Copy link
Contributor Author

@schmidt-sebastian schmidt-sebastian Apr 23, 2020

Choose a reason for hiding this comment

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

The issue here is actually the success case - the problem here is the ViewSnapshot for the initial listen needs to be raised. The current flow is:

  • EventManager's listen() registers target in local data structure
  • EventManager calls SyncEngine listen()
  • SyncEngine's listen() calls back EventManager
  • EventManager looks in local data structure to raise snapshot
  • SyncEngine finishes listen()
  • EventManager finishes listen()

This code seems fragile to me The callback in SyncEngine relies on an implementation detail of EventManager. I was very surprised that re-arranging the internal code of EventManager broke tests that were unrelated to my change. Because of this, I think it is better to return a ViewSnapshot.

Having said that, I was able to remove this type after all because EventManager doesn't actually use the target ID (other than to pass it through). I updated listen() to just return the initial snapshot.

@@ -246,8 +252,7 @@ export class SyncEngine implements RemoteSyncer, SharedClientStateSyncer {
}
}

this.syncEngineListener!.onWatchChange([viewSnapshot]);
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 am not sure I fully understand this, but I hope that my previous comment reliefs your concerns. Let me know if it doesn't.

@schmidt-sebastian
Copy link
Contributor Author

Friendly bump :)

listener.onError(
new FirestoreError(
Code.UNAVAILABLE,
`Failed to register query: ${e}`
Copy link
Contributor

Choose a reason for hiding this comment

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

A few thoughts on this:

  • From a user's point of view they listen to queries (or get them) but we don't have a public concept of "registration". I think it's probably fine to just say "failed to listen to query", "failed to start a listener to query" or maybe something more generic like "failed to start query".
  • We should include details of the query that failed (a toString or similar). Whenever people are filing issues about broken queries our first instructions are to have people go figure out which query is causing the problem and they're usually at a loss. We should just include that in the error message.

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 to use "initialize" and also included the Query in the message.

queryInfo.viewSnap = await this.syncEngine.listen(query);
}

this.queries.set(query, queryInfo!);
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm surprised the ! is required here. Isn't queryInfo guaranteed to be set here? (What confuses me is you're not null-checking the assignment to e.g. queryInfo.viewSnap).

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 indeed not needed. It might have been needed before the refactor, but now TS can determine that it is set.

.expectEvents(query1, {})
.failDatabase()
.userListens(query2)
.expectEvents(query2, { errorCode: Code.UNKNOWN })
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 the source of the UNKNOWN here? The spec test runner seems to be doing new FirestoreError(Code.UNAVAILABLE, e.message). Is anything even handling errorCode?

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 don't validate the error code at all. I added this validation.

}

if (firstListen) {
queryInfo.viewSnap = await this.syncEngine.listen(query);
Copy link
Contributor

Choose a reason for hiding this comment

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

Nothing seems to populate queryInfo.targetId anymore?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

queryInfo.targetId is never read and I removed it.

@@ -59,15 +67,14 @@ export class EventManager implements SyncEngineListener {
this.syncEngine.subscribe(this);
}

listen(listener: QueryListener): Promise<TargetId> {
async listen(listener: QueryListener): Promise<void> {
Copy link
Contributor

Choose a reason for hiding this comment

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

I see what you mean; the spec test has to emulate this for itself. That's not completely awful but you're probably right that the other way was better because it was testable.

Would it help to put the error handling in SyncEngine?

@wilhuff wilhuff assigned schmidt-sebastian and unassigned wilhuff Apr 28, 2020
@schmidt-sebastian schmidt-sebastian removed their assignment Apr 29, 2020
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

@wilhuff wilhuff assigned schmidt-sebastian and unassigned wilhuff May 1, 2020
@schmidt-sebastian schmidt-sebastian merged commit 174521a into master May 1, 2020
@firebase firebase locked and limited conversation to collaborators Jun 1, 2020
@schmidt-sebastian schmidt-sebastian deleted the mrschmidt/initialwatch branch November 9, 2020 22:37
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