Skip to content

Commit 5c41eab

Browse files
Take WriteStream offline when IndexedDB is unavailable (#2995)
1 parent 2a99add commit 5c41eab

File tree

4 files changed

+199
-52
lines changed

4 files changed

+199
-52
lines changed

packages/firestore/CHANGELOG.md

+12-2
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
11
# Unreleased
2+
- [changed] All known failure cases for Indexed-related crashes have now been
3+
addressed. Instead of crashing the client, IndexedDB failures will result
4+
in rejected operations (e.g. rejected Writes or errored Query listeners).
5+
If these rejections surface in your app, you can retry these operations
6+
when IndexedDB access is restored.
7+
IndexedDB failures that occur due to background work are automatically
8+
retried.
9+
10+
If you continue to see Indexed-related crashes, we appreciate feedback
11+
(https://github.com/firebase/firebase-js-sdk/issues/2755).
12+
13+
# Released
214
- [fixed] Fixed an issue that could cause Firestore to temporarily go
315
offline when a Window visibility event occurred.
416
- [feature] Added support for calling `FirebaseFiresore.settings` with
517
`{ ignoreUndefinedProperties: true }`. When set, Firestore ignores
618
undefined properties inside objects rather than rejecting the API call.
7-
8-
# Released
919
- [fixed] Fixed a regression introduced in v7.14.2 that incorrectly applied
1020
a `FieldValue.increment` in combination with `set({...}, {merge: true})`.
1121
- [fixed] Firestore now rejects `onSnapshot()` listeners if they cannot be

packages/firestore/src/core/sync_engine.ts

+16-16
Original file line numberDiff line numberDiff line change
@@ -525,18 +525,18 @@ export class SyncEngine implements RemoteSyncer {
525525

526526
const batchId = mutationBatchResult.batch.batchId;
527527

528-
// The local store may or may not be able to apply the write result and
529-
// raise events immediately (depending on whether the watcher is caught
530-
// up), so we raise user callbacks first so that they consistently happen
531-
// before listen events.
532-
this.processUserCallback(batchId, /*error=*/ null);
533-
534-
this.triggerPendingWritesCallbacks(batchId);
535-
536528
try {
537529
const changes = await this.localStore.acknowledgeBatch(
538530
mutationBatchResult
539531
);
532+
533+
// The local store may or may not be able to apply the write result and
534+
// raise events immediately (depending on whether the watcher is caught
535+
// up), so we raise user callbacks first so that they consistently happen
536+
// before listen events.
537+
this.processUserCallback(batchId, /*error=*/ null);
538+
this.triggerPendingWritesCallbacks(batchId);
539+
540540
this.sharedClientState.updateMutationState(batchId, 'acknowledged');
541541
await this.emitNewSnapsAndNotifyLocalStore(changes);
542542
} catch (error) {
@@ -550,16 +550,16 @@ export class SyncEngine implements RemoteSyncer {
550550
): Promise<void> {
551551
this.assertSubscribed('rejectFailedWrite()');
552552

553-
// The local store may or may not be able to apply the write result and
554-
// raise events immediately (depending on whether the watcher is caught up),
555-
// so we raise user callbacks first so that they consistently happen before
556-
// listen events.
557-
this.processUserCallback(batchId, error);
558-
559-
this.triggerPendingWritesCallbacks(batchId);
560-
561553
try {
562554
const changes = await this.localStore.rejectBatch(batchId);
555+
556+
// The local store may or may not be able to apply the write result and
557+
// raise events immediately (depending on whether the watcher is caught up),
558+
// so we raise user callbacks first so that they consistently happen before
559+
// listen events.
560+
this.processUserCallback(batchId, error);
561+
this.triggerPendingWritesCallbacks(batchId);
562+
563563
this.sharedClientState.updateMutationState(batchId, 'rejected', error);
564564
await this.emitNewSnapsAndNotifyLocalStore(changes);
565565
} catch (error) {

packages/firestore/src/remote/remote_store.ts

+62-34
Original file line numberDiff line numberDiff line change
@@ -443,10 +443,17 @@ export class RemoteStore implements TargetMetadataProvider {
443443

444444
/**
445445
* Recovery logic for IndexedDB errors that takes the network offline until
446-
* IndexedDb probing succeeds. Retries are scheduled with backoff using
447-
* `enqueueRetryable()`.
446+
* `op` succeeds. Retries are scheduled with backoff using
447+
* `enqueueRetryable()`. If `op()` is not provided, IndexedDB access is
448+
* validated via a generic operation.
449+
*
450+
* The returned Promise is resolved once the network is disabled and before
451+
* any retry attempt.
448452
*/
449-
private async disableNetworkUntilRecovery(e: FirestoreError): Promise<void> {
453+
private async disableNetworkUntilRecovery(
454+
e: FirestoreError,
455+
op?: () => Promise<unknown>
456+
): Promise<void> {
450457
if (isIndexedDbTransactionError(e)) {
451458
debugAssert(
452459
!this.indexedDbFailed,
@@ -458,13 +465,17 @@ export class RemoteStore implements TargetMetadataProvider {
458465
await this.disableNetworkInternal();
459466
this.onlineStateTracker.set(OnlineState.Offline);
460467

468+
if (!op) {
469+
// Use a simple read operation to determine if IndexedDB recovered.
470+
// Ideally, we would expose a health check directly on SimpleDb, but
471+
// RemoteStore only has access to persistence through LocalStore.
472+
op = () => this.localStore.getLastRemoteSnapshotVersion();
473+
}
474+
461475
// Probe IndexedDB periodically and re-enable network
462476
this.asyncQueue.enqueueRetryable(async () => {
463477
logDebug(LOG_TAG, 'Retrying IndexedDB access');
464-
// Issue a simple read operation to determine if IndexedDB recovered.
465-
// Ideally, we would expose a health check directly on SimpleDb, but
466-
// RemoteStore only has access to persistence through LocalStore.
467-
await this.localStore.getLastRemoteSnapshotVersion();
478+
await op!();
468479
this.indexedDbFailed = false;
469480
await this.enableNetworkInternal();
470481
});
@@ -473,6 +484,14 @@ export class RemoteStore implements TargetMetadataProvider {
473484
}
474485
}
475486

487+
/**
488+
* Executes `op`. If `op` fails, takes the network offline until `op`
489+
* succeeds. Returns after the first attempt.
490+
*/
491+
private executeWithRecovery(op: () => Promise<void>): Promise<void> {
492+
return op().catch(e => this.disableNetworkUntilRecovery(e, op));
493+
}
494+
476495
/**
477496
* Takes a batch of changes from the Datastore, repackages them as a
478497
* RemoteEvent, and passes that on to the listener, which is typically the
@@ -567,22 +586,28 @@ export class RemoteStore implements TargetMetadataProvider {
567586
* Starts the write stream if necessary.
568587
*/
569588
async fillWritePipeline(): Promise<void> {
570-
if (this.canAddToWritePipeline()) {
571-
const lastBatchIdRetrieved =
572-
this.writePipeline.length > 0
573-
? this.writePipeline[this.writePipeline.length - 1].batchId
574-
: BATCHID_UNKNOWN;
575-
const batch = await this.localStore.nextMutationBatch(
576-
lastBatchIdRetrieved
577-
);
589+
let lastBatchIdRetrieved =
590+
this.writePipeline.length > 0
591+
? this.writePipeline[this.writePipeline.length - 1].batchId
592+
: BATCHID_UNKNOWN;
578593

579-
if (batch === null) {
580-
if (this.writePipeline.length === 0) {
581-
this.writeStream.markIdle();
594+
while (this.canAddToWritePipeline()) {
595+
try {
596+
const batch = await this.localStore.nextMutationBatch(
597+
lastBatchIdRetrieved
598+
);
599+
600+
if (batch === null) {
601+
if (this.writePipeline.length === 0) {
602+
this.writeStream.markIdle();
603+
}
604+
break;
605+
} else {
606+
lastBatchIdRetrieved = batch.batchId;
607+
this.addToWritePipeline(batch);
582608
}
583-
} else {
584-
this.addToWritePipeline(batch);
585-
await this.fillWritePipeline();
609+
} catch (e) {
610+
await this.disableNetworkUntilRecovery(e);
586611
}
587612
}
588613

@@ -649,7 +674,7 @@ export class RemoteStore implements TargetMetadataProvider {
649674
}
650675
}
651676

652-
private onMutationResult(
677+
private async onMutationResult(
653678
commitVersion: SnapshotVersion,
654679
results: MutationResult[]
655680
): Promise<void> {
@@ -661,11 +686,14 @@ export class RemoteStore implements TargetMetadataProvider {
661686
);
662687
const batch = this.writePipeline.shift()!;
663688
const success = MutationBatchResult.from(batch, commitVersion, results);
664-
return this.syncEngine.applySuccessfulWrite(success).then(() => {
665-
// It's possible that with the completion of this mutation another
666-
// slot has freed up.
667-
return this.fillWritePipeline();
668-
});
689+
690+
await this.executeWithRecovery(() =>
691+
this.syncEngine.applySuccessfulWrite(success)
692+
);
693+
694+
// It's possible that with the completion of this mutation another
695+
// slot has freed up.
696+
await this.fillWritePipeline();
669697
}
670698

671699
private async onWriteStreamClose(error?: FirestoreError): Promise<void> {
@@ -705,13 +733,13 @@ export class RemoteStore implements TargetMetadataProvider {
705733
// restart.
706734
this.writeStream.inhibitBackoff();
707735

708-
return this.syncEngine
709-
.rejectFailedWrite(batch.batchId, error)
710-
.then(() => {
711-
// It's possible that with the completion of this mutation
712-
// another slot has freed up.
713-
return this.fillWritePipeline();
714-
});
736+
await this.executeWithRecovery(() =>
737+
this.syncEngine.rejectFailedWrite(batch.batchId, error)
738+
);
739+
740+
// It's possible that with the completion of this mutation
741+
// another slot has freed up.
742+
await this.fillWritePipeline();
715743
} else {
716744
// Transient error, just let the retry logic kick in.
717745
}

packages/firestore/test/unit/specs/recovery_spec.test.ts

+109
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,115 @@ describeSpec('Persistence Recovery', ['no-ios', 'no-android'], () => {
336336
.expectEvents(query, { metadata: [doc1, doc3] });
337337
});
338338

339+
specTest('Recovers when write acknowledgment cannot be persisted', [], () => {
340+
return spec()
341+
.userSets('collection/a', { v: 1 })
342+
.userSets('collection/b', { v: 2 })
343+
.userSets('collection/c', { v: 3 })
344+
.writeAcks('collection/a', 1)
345+
.failDatabaseTransactions('Acknowledge batch')
346+
.writeAcks('collection/b', 2, { expectUserCallback: false })
347+
.recoverDatabase()
348+
.runTimer(TimerId.AsyncQueueRetry)
349+
.expectUserCallbacks({ acknowledged: ['collection/b'] })
350+
.writeAcks('collection/c', 1);
351+
});
352+
353+
specTest('Recovers when write rejection cannot be persisted', [], () => {
354+
return spec()
355+
.userPatches('collection/a', { v: 1 })
356+
.userPatches('collection/a', { v: 2 })
357+
.userPatches('collection/c', { v: 3 })
358+
.failWrite(
359+
'collection/a',
360+
new RpcError(Code.FAILED_PRECONDITION, 'Simulated test error')
361+
)
362+
.failDatabaseTransactions('Reject batch')
363+
.failWrite(
364+
'collection/b',
365+
new RpcError(Code.FAILED_PRECONDITION, 'Simulated test error'),
366+
{ expectUserCallback: false }
367+
)
368+
.recoverDatabase()
369+
.runTimer(TimerId.AsyncQueueRetry)
370+
.expectUserCallbacks({ rejected: ['collection/a'] })
371+
.failWrite(
372+
'collection/c',
373+
new RpcError(Code.FAILED_PRECONDITION, 'Simulated test error')
374+
);
375+
});
376+
377+
specTest(
378+
'Recovers when write acknowledgment cannot be persisted (with restart)',
379+
['durable-persistence'],
380+
() => {
381+
// This test verifies the current behavior of the client, which is not
382+
// ideal. Instead of resending the write to 'collection/b' (whose
383+
// rejection failed with an IndexedDB failure), the client should drop the
384+
// write.
385+
return spec()
386+
.userSets('collection/a', { v: 1 })
387+
.userSets('collection/b', { v: 2 })
388+
.userSets('collection/c', { v: 3 })
389+
.writeAcks('collection/a', 1)
390+
.failDatabaseTransactions('Acknowledge batch')
391+
.writeAcks('collection/b', 2, {
392+
expectUserCallback: false,
393+
keepInQueue: true
394+
})
395+
.restart()
396+
.expectNumOutstandingWrites(2)
397+
.writeAcks('collection/b', 2, { expectUserCallback: false })
398+
.writeAcks('collection/c', 3, { expectUserCallback: false });
399+
}
400+
);
401+
402+
specTest('Writes are pending until acknowledgement is persisted', [], () => {
403+
const query = Query.atPath(path('collection'));
404+
const doc1Local = doc(
405+
'collection/a',
406+
0,
407+
{ v: 1 },
408+
{ hasLocalMutations: true }
409+
);
410+
const doc1 = doc('collection/a', 1001, { v: 1 });
411+
const doc2Local = doc(
412+
'collection/b',
413+
0,
414+
{ v: 2 },
415+
{ hasLocalMutations: true }
416+
);
417+
const doc2 = doc('collection/b', 1002, { v: 2 });
418+
return (
419+
spec()
420+
.userListens(query)
421+
.watchAcksFull(query, 1000)
422+
.expectEvents(query, {})
423+
.userSets('collection/a', { v: 1 })
424+
.expectEvents(query, { added: [doc1Local], hasPendingWrites: true })
425+
.userSets('collection/b', { v: 2 })
426+
.expectEvents(query, { added: [doc2Local], hasPendingWrites: true })
427+
.failDatabaseTransactions('Acknowledge batch')
428+
.writeAcks('collection/a', 1, { expectUserCallback: false })
429+
// The write ack cannot be persisted and the client goes offline, which
430+
// clears all active targets, but doesn't raise a new snapshot since
431+
// the document is still marked `hasPendingWrites`.
432+
.expectEvents(query, { fromCache: true, hasPendingWrites: true })
433+
.expectActiveTargets()
434+
.recoverDatabase()
435+
.runTimer(TimerId.AsyncQueueRetry)
436+
// Client is back online
437+
.expectActiveTargets({ query, resumeToken: 'resume-token-1000' })
438+
.expectUserCallbacks({ acknowledged: ['collection/a'] })
439+
.watchAcksFull(query, 1001, doc1)
440+
.expectEvents(query, { metadata: [doc1], hasPendingWrites: true })
441+
.writeAcks('collection/b', 2)
442+
.watchSends({ affects: [query] }, doc2)
443+
.watchSnapshots(1002)
444+
.expectEvents(query, { metadata: [doc2] })
445+
);
446+
});
447+
339448
specTest(
340449
'Surfaces local documents if notifyLocalViewChanges fails',
341450
[],

0 commit comments

Comments
 (0)