-
Notifications
You must be signed in to change notification settings - Fork 928
Take WatchStream offline when IndexedDB is unavailable #3010
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
Changes from 2 commits
3aa30fd
4593a43
f5f56f2
9641975
b2b6a58
7cc41eb
3dff5d0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -123,6 +123,12 @@ export class RemoteStore implements TargetMetadataProvider { | |
|
||
private isPrimary = false; | ||
|
||
/** | ||
* A barrier that prevents the restart of the network streams. Incremented for | ||
* each IndexedDB failure until access becomes available again. | ||
*/ | ||
private indexedDbFailureBarrier = 0; | ||
|
||
private onlineStateTracker: OnlineStateTracker; | ||
|
||
constructor( | ||
|
@@ -132,7 +138,7 @@ export class RemoteStore implements TargetMetadataProvider { | |
private localStore: LocalStore, | ||
/** The client-side proxy for interacting with the backend. */ | ||
private datastore: Datastore, | ||
asyncQueue: AsyncQueue, | ||
private asyncQueue: AsyncQueue, | ||
onlineStateHandler: (onlineState: OnlineState) => void, | ||
connectivityMonitor: ConnectivityMonitor | ||
) { | ||
|
@@ -184,9 +190,12 @@ export class RemoteStore implements TargetMetadataProvider { | |
} | ||
|
||
/** Re-enables the network. Idempotent. */ | ||
async enableNetwork(): Promise<void> { | ||
enableNetwork(): Promise<void> { | ||
this.networkEnabled = true; | ||
return this.enableNetworkInternal(); | ||
} | ||
|
||
private async enableNetworkInternal(): Promise<void> { | ||
if (this.canUseNetwork()) { | ||
this.writeStream.lastStreamToken = await this.localStore.getLastStreamToken(); | ||
|
||
|
@@ -339,7 +348,11 @@ export class RemoteStore implements TargetMetadataProvider { | |
} | ||
|
||
canUseNetwork(): boolean { | ||
return this.isPrimary && this.networkEnabled; | ||
return ( | ||
this.indexedDbFailureBarrier === 0 && | ||
this.isPrimary && | ||
this.networkEnabled | ||
); | ||
} | ||
|
||
private cleanUpWatchStreamState(): void { | ||
|
@@ -391,7 +404,13 @@ export class RemoteStore implements TargetMetadataProvider { | |
) { | ||
// There was an error on a target, don't wait for a consistent snapshot | ||
// to raise events | ||
return this.handleTargetError(watchChange); | ||
try { | ||
await this.handleTargetError(watchChange); | ||
} catch (e) { | ||
logDebug(LOG_TAG, 'Failed to remove target: ' + e); | ||
await this.disableNetworkUntilRecovery(e); | ||
} | ||
return; | ||
} | ||
|
||
if (watchChange instanceof DocumentWatchChange) { | ||
|
@@ -407,15 +426,43 @@ export class RemoteStore implements TargetMetadataProvider { | |
} | ||
|
||
if (!snapshotVersion.isEqual(SnapshotVersion.min())) { | ||
const lastRemoteSnapshotVersion = await this.localStore.getLastRemoteSnapshotVersion(); | ||
if (snapshotVersion.compareTo(lastRemoteSnapshotVersion) >= 0) { | ||
// We have received a target change with a global snapshot if the snapshot | ||
// version is not equal to SnapshotVersion.min(). | ||
await this.raiseWatchSnapshot(snapshotVersion); | ||
try { | ||
const lastRemoteSnapshotVersion = await this.localStore.getLastRemoteSnapshotVersion(); | ||
if (snapshotVersion.compareTo(lastRemoteSnapshotVersion) >= 0) { | ||
// We have received a target change with a global snapshot if the snapshot | ||
// version is not equal to SnapshotVersion.min(). | ||
await this.raiseWatchSnapshot(snapshotVersion); | ||
} | ||
} catch (e) { | ||
logDebug(LOG_TAG, 'Failed to raise snapshot: ' + e); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there any identifying information about the snapshot that we can log here? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am not sure if it will help, but I added the snapshot version. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The snapshot version probably isn't that useful, but the target ID would be. Anything to help associate this message with its context. If the targetId isn't available going back to what you had before is probably better. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I changed this back to the original message. The target IDs are not easily available:
If I understand the flow correctly, we should always be in a WatchTargetChange to raise a snapshot, but I don't want to bake this assumption into the code for better logging. I can invest some time here, but the targets are already in the Watch response that we log right before. |
||
await this.disableNetworkUntilRecovery(e); | ||
} | ||
} | ||
} | ||
|
||
/** | ||
* Recovery logic for IndexedDB errors that takes the network offline until | ||
* IndexedDb probing succeeds. Retries are scheduled with backoff using | ||
* `enqueueRetryable()`. | ||
*/ | ||
private async disableNetworkUntilRecovery(e: FirestoreError): Promise<void> { | ||
if (e.name === 'IndexedDbTransactionError') { | ||
++this.indexedDbFailureBarrier; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This mechanism seems more complicated/fiddly than it needs to be. If we get multiple watch change events in a row, this is going to bump this It seems like instead of keeping a count, we could instead keep a boolean like
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You say "complicated/fiddly", I say "advanced" :) We should only ever get one of these events, since we go offline right away and discard all further messages from the stream. I realize that this is a good argument for making this a boolean and updated the code accordingly. |
||
await this.disableNetworkInternal(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That will create some flicker, but it is the right thing to do. Updated. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I had to add the state transition here since we don't raise offline events when |
||
this.asyncQueue.enqueueRetryable(async () => { | ||
logDebug(LOG_TAG, 'Retrying IndexedDB access'); | ||
// Issue a simple read operation to determine if IndexedDB recovered. | ||
// Ideally, we would expose a health check directly on SimpleDb, but | ||
// RemoteStore only has access to persistence through LocalStore. | ||
await this.localStore.getLastRemoteSnapshotVersion(); | ||
--this.indexedDbFailureBarrier; | ||
await this.enableNetworkInternal(); | ||
}); | ||
} else { | ||
throw e; | ||
} | ||
} | ||
|
||
/** | ||
* Takes a batch of changes from the Datastore, repackages them as a | ||
* RemoteEvent, and passes that on to the listener, which is typically the | ||
|
@@ -486,21 +533,19 @@ export class RemoteStore implements TargetMetadataProvider { | |
} | ||
|
||
/** Handles an error on a target */ | ||
private handleTargetError(watchChange: WatchTargetChange): Promise<void> { | ||
private async handleTargetError( | ||
watchChange: WatchTargetChange | ||
): Promise<void> { | ||
debugAssert(!!watchChange.cause, 'Handling target error without a cause'); | ||
const error = watchChange.cause!; | ||
let promiseChain = Promise.resolve(); | ||
watchChange.targetIds.forEach(targetId => { | ||
promiseChain = promiseChain.then(async () => { | ||
// A watched target might have been removed already. | ||
if (this.listenTargets.has(targetId)) { | ||
this.listenTargets.delete(targetId); | ||
this.watchChangeAggregator!.removeTarget(targetId); | ||
return this.syncEngine.rejectListen(targetId, error); | ||
} | ||
}); | ||
}); | ||
return promiseChain; | ||
for (const targetId of watchChange.targetIds) { | ||
// A watched target might have been removed already. | ||
if (this.listenTargets.has(targetId)) { | ||
await this.syncEngine.rejectListen(targetId, error); | ||
this.listenTargets.delete(targetId); | ||
this.watchChangeAggregator!.removeTarget(targetId); | ||
} | ||
} | ||
} | ||
|
||
/** | ||
|
@@ -637,25 +682,21 @@ export class RemoteStore implements TargetMetadataProvider { | |
// If the write stream closed due to an error, invoke the error callbacks if | ||
// there are pending writes. | ||
if (error && this.writePipeline.length > 0) { | ||
// A promise that is resolved after we processed the error | ||
let errorHandling: Promise<void>; | ||
if (this.writeStream.handshakeComplete) { | ||
// This error affects the actual write. | ||
errorHandling = this.handleWriteError(error!); | ||
await this.handleWriteError(error!); | ||
} else { | ||
// If there was an error before the handshake has finished, it's | ||
// possible that the server is unable to process the stream token | ||
// we're sending. (Perhaps it's too old?) | ||
errorHandling = this.handleHandshakeError(error!); | ||
await this.handleHandshakeError(error!); | ||
} | ||
|
||
return errorHandling.then(() => { | ||
// The write stream might have been started by refilling the write | ||
// pipeline for failed writes | ||
if (this.shouldStartWriteStream()) { | ||
this.startWriteStream(); | ||
} | ||
}); | ||
// The write stream might have been started by refilling the write | ||
// pipeline for failed writes | ||
if (this.shouldStartWriteStream()) { | ||
this.startWriteStream(); | ||
} | ||
} | ||
// No pending writes, nothing to do | ||
} | ||
|
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.
Can we log the query associated with the target here?
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.
Unless we go out of our way, we don't have access to the query here. I logged the target ID instead. We should however already have this information from the Watch stream.
FWIW, I didn't add this logging to begin with since this is a debug log. The other logs were error logs in response to user behavior. They are always visible, and often without context.