Skip to content

Commit a82af58

Browse files
Retry WebStorage operations (#2879)
1 parent daf63c2 commit a82af58

13 files changed

+452
-65
lines changed

packages/firestore/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
# Unreleased
2+
- [fixed] Fixed a source of IndexedDB-related crashes for tabs that receive
3+
multi-tab notifications while the file system is locked.
4+
5+
# 1.10.2
26
- [fixed] Temporarily reverted the use of window.crypto to generate document
37
IDs to address compatibility issues with IE 11, WebWorkers, and React Native.
48
- [changed] Firestore now limits the number of concurrent document lookups it

packages/firestore/src/core/component_provider.ts

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ import { IndexFreeQueryEngine } from '../local/index_free_query_engine';
4040
import { IndexedDbPersistence } from '../local/indexeddb_persistence';
4141
import {
4242
MemoryEagerDelegate,
43-
MemoryLruDelegate,
4443
MemoryPersistence
4544
} from '../local/memory_persistence';
4645

@@ -189,23 +188,6 @@ export class MemoryComponentProvider {
189188
}
190189
}
191190

192-
/**
193-
* Provides all components needed for Firestore with in-memory persistence.
194-
* Uses LRU garbage collection.
195-
*/
196-
export class MemoryLruComponentProvider extends MemoryComponentProvider {
197-
createPersistence(cfg: ComponentConfiguration): Persistence {
198-
debugAssert(
199-
!cfg.persistenceSettings.durable,
200-
'Can only start memory persistence'
201-
);
202-
return new MemoryPersistence(
203-
cfg.clientId,
204-
p => new MemoryLruDelegate(p, LruParams.DEFAULT)
205-
);
206-
}
207-
}
208-
209191
/**
210192
* Provides all components needed for Firestore with IndexedDB persistence.
211193
*/

packages/firestore/src/core/transaction_runner.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
22
* @license
3-
* Copyright 2019 Google Inc.
3+
* Copyright 2019 Google LLC
44
*
55
* Licensed under the Apache License, Version 2.0 (the "License");
66
* you may not use this file except in compliance with the License.
@@ -42,7 +42,7 @@ export class TransactionRunner<T> {
4242
) {
4343
this.backoff = new ExponentialBackoff(
4444
this.asyncQueue,
45-
TimerId.RetryTransaction
45+
TimerId.TransactionRetry
4646
);
4747
}
4848

packages/firestore/src/local/shared_client_state.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -755,7 +755,7 @@ export class WebStorageSharedClientState implements SharedClientState {
755755
return;
756756
}
757757

758-
this.queue.enqueueAndForget(async () => {
758+
this.queue.enqueueRetryable(async () => {
759759
if (!this.started) {
760760
this.earlyEvents.push(event);
761761
return;

packages/firestore/src/remote/backoff.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ export class ExponentialBackoff {
119119
desiredDelayWithJitterMs - delaySoFarMs
120120
);
121121

122-
if (this.currentBaseMs > 0) {
122+
if (remainingDelayMs > 0) {
123123
logDebug(
124124
LOG_TAG,
125125
`Backing off for ${remainingDelayMs} ms ` +

packages/firestore/src/util/async_queue.ts

Lines changed: 69 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,12 @@
1717

1818
import { debugAssert, fail } from './assert';
1919
import { Code, FirestoreError } from './error';
20-
import { logError } from './log';
20+
import { logDebug, logError } from './log';
2121
import { CancelablePromise, Deferred } from './promise';
22+
import { ExponentialBackoff } from '../remote/backoff';
23+
import { PlatformSupport } from '../platform/platform';
24+
25+
const LOG_TAG = 'AsyncQueue';
2226

2327
// Accept any return type from setTimeout().
2428
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -66,7 +70,13 @@ export const enum TimerId {
6670
* A timer used to retry transactions. Since there can be multiple concurrent
6771
* transactions, multiple of these may be in the queue at a given time.
6872
*/
69-
RetryTransaction = 'retry_transaction'
73+
TransactionRetry = 'transaction_retry',
74+
75+
/**
76+
* A timer used to retry operations scheduled via retryable AsyncQueue
77+
* operations.
78+
*/
79+
AsyncQueueRetry = 'async_queue_retry'
7080
}
7181

7282
/**
@@ -195,6 +205,10 @@ export class AsyncQueue {
195205
// The last promise in the queue.
196206
private tail: Promise<unknown> = Promise.resolve();
197207

208+
// The last retryable operation. Retryable operation are run in order and
209+
// retried with backoff.
210+
private retryableTail: Promise<void> = Promise.resolve();
211+
198212
// Is this AsyncQueue being shut down? Once it is set to true, it will not
199213
// be changed again.
200214
private _isShuttingDown: boolean = false;
@@ -213,6 +227,24 @@ export class AsyncQueue {
213227
// List of TimerIds to fast-forward delays for.
214228
private timerIdsToSkip: TimerId[] = [];
215229

230+
// Backoff timer used to schedule retries for retryable operations
231+
private backoff = new ExponentialBackoff(this, TimerId.AsyncQueueRetry);
232+
233+
// Visibility handler that triggers an immediate retry of all retryable
234+
// operations. Meant to speed up recovery when we regain file system access
235+
// after page comes into foreground.
236+
private visibilityHandler = (): void => {
237+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
238+
this.runDelayedOperationsEarly(TimerId.AsyncQueueRetry);
239+
};
240+
241+
constructor() {
242+
const window = PlatformSupport.getPlatform().window;
243+
if (window) {
244+
window.addEventListener('visibilitychange', this.visibilityHandler);
245+
}
246+
}
247+
216248
// Is this AsyncQueue being shut down? If true, this instance will not enqueue
217249
// any new operations, Promises from enqueue requests will not resolve.
218250
get isShuttingDown(): boolean {
@@ -262,6 +294,10 @@ export class AsyncQueue {
262294
this.verifyNotFailed();
263295
if (!this._isShuttingDown) {
264296
this._isShuttingDown = true;
297+
const window = PlatformSupport.getPlatform().window;
298+
if (window) {
299+
window.removeEventListener('visibilitychange', this.visibilityHandler);
300+
}
265301
await this.enqueueEvenAfterShutdown(op);
266302
}
267303
}
@@ -279,6 +315,37 @@ export class AsyncQueue {
279315
return this.enqueueInternal(op);
280316
}
281317

318+
/**
319+
* Enqueue a retryable operation.
320+
*
321+
* A retryable operation is rescheduled with backoff if it fails with any
322+
* exception. All retryable operations are executed in order and only run
323+
* if all prior operations were retried successfully.
324+
*/
325+
enqueueRetryable(op: () => Promise<void>): void {
326+
this.verifyNotFailed();
327+
328+
if (this._isShuttingDown) {
329+
return;
330+
}
331+
332+
this.retryableTail = this.retryableTail.then(() => {
333+
const deferred = new Deferred<void>();
334+
const retryingOp = async (): Promise<void> => {
335+
try {
336+
await op();
337+
deferred.resolve();
338+
this.backoff.reset();
339+
} catch (e) {
340+
logDebug(LOG_TAG, 'Retryable operation failed: ' + e.message);
341+
this.backoff.backoffAndRun(retryingOp);
342+
}
343+
};
344+
this.enqueueAndForget(retryingOp);
345+
return deferred.promise;
346+
});
347+
}
348+
282349
private enqueueInternal<T extends unknown>(op: () => Promise<T>): Promise<T> {
283350
const newTail = this.tail.then(() => {
284351
this.operationInProgress = true;
@@ -399,12 +466,6 @@ export class AsyncQueue {
399466
runDelayedOperationsEarly(lastTimerId: TimerId): Promise<void> {
400467
// Note that draining may generate more delayed ops, so we do that first.
401468
return this.drain().then(() => {
402-
debugAssert(
403-
lastTimerId === TimerId.All ||
404-
this.containsDelayedOperation(lastTimerId),
405-
`Attempted to drain to missing operation ${lastTimerId}`
406-
);
407-
408469
// Run ops in the same order they'd run if they ran naturally.
409470
this.delayedOperations.sort((a, b) => a.targetTimeMs - b.targetTimeMs);
410471

packages/firestore/test/integration/api_internal/transaction.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
22
* @license
3-
* Copyright 2019 Google Inc.
3+
* Copyright 2019 Google LLC
44
*
55
* Licensed under the Apache License, Version 2.0 (the "License");
66
* you may not use this file except in compliance with the License.
@@ -36,7 +36,7 @@ apiDescribe(
3636
let started = 0;
3737

3838
return integrationHelpers.withTestDb(persistence, db => {
39-
asyncQueue(db).skipDelaysForTimerId(TimerId.RetryTransaction);
39+
asyncQueue(db).skipDelaysForTimerId(TimerId.TransactionRetry);
4040
const doc = db.collection('counters').doc();
4141
return doc
4242
.set({
@@ -93,7 +93,7 @@ apiDescribe(
9393
let counter = 0;
9494

9595
return integrationHelpers.withTestDb(persistence, db => {
96-
asyncQueue(db).skipDelaysForTimerId(TimerId.RetryTransaction);
96+
asyncQueue(db).skipDelaysForTimerId(TimerId.TransactionRetry);
9797
const doc = db.collection('counters').doc();
9898
return doc
9999
.set({
@@ -148,7 +148,7 @@ apiDescribe(
148148

149149
it('handle reading a doc twice with different versions', () => {
150150
return integrationHelpers.withTestDb(persistence, db => {
151-
asyncQueue(db).skipDelaysForTimerId(TimerId.RetryTransaction);
151+
asyncQueue(db).skipDelaysForTimerId(TimerId.TransactionRetry);
152152
const doc = db.collection('counters').doc();
153153
let counter = 0;
154154
return doc
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/**
2+
* @license
3+
* Copyright 2020 Google LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import { describeSpec, specTest } from './describe_spec';
19+
import { client } from './spec_builder';
20+
import { TimerId } from '../../../src/util/async_queue';
21+
import { Query } from '../../../src/core/query';
22+
import { path } from '../../util/helpers';
23+
24+
describeSpec(
25+
'Persistence Recovery',
26+
['durable-persistence', 'no-ios', 'no-android'],
27+
() => {
28+
specTest(
29+
'Write is acknowledged by primary client (with recovery)',
30+
['multi-client'],
31+
() => {
32+
return (
33+
client(0)
34+
.expectPrimaryState(true)
35+
.client(1)
36+
.expectPrimaryState(false)
37+
.userSets('collection/a', { v: 1 })
38+
.failDatabase()
39+
.client(0)
40+
.writeAcks('collection/a', 1, { expectUserCallback: false })
41+
.client(1)
42+
// Client 1 has received the WebStorage notification that the write
43+
// has been acknowledged, but failed to process the change. Hence,
44+
// we did not get a user callback. We schedule the first retry and
45+
// make sure that it also does not get processed until
46+
// `recoverDatabase` is called.
47+
.runTimer(TimerId.AsyncQueueRetry)
48+
.recoverDatabase()
49+
.runTimer(TimerId.AsyncQueueRetry)
50+
.expectUserCallbacks({
51+
acknowledged: ['collection/a']
52+
})
53+
);
54+
}
55+
);
56+
57+
specTest(
58+
'Query raises events in secondary client (with recovery)',
59+
['multi-client'],
60+
() => {
61+
const query = Query.atPath(path('collection'));
62+
63+
return client(0)
64+
.expectPrimaryState(true)
65+
.client(1)
66+
.expectPrimaryState(false)
67+
.userListens(query)
68+
.failDatabase()
69+
.client(0)
70+
.expectListen(query)
71+
.watchAcksFull(query, 1000)
72+
.client(1)
73+
.recoverDatabase()
74+
.runTimer(TimerId.AsyncQueueRetry)
75+
.expectEvents(query, {});
76+
}
77+
);
78+
}
79+
);

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,24 @@ export class SpecBuilder {
418418
return this;
419419
}
420420

421+
/** Fails all database operations until `recoverDatabase()` is called. */
422+
failDatabase(): this {
423+
this.nextStep();
424+
this.currentStep = {
425+
failDatabase: true
426+
};
427+
return this;
428+
}
429+
430+
/** Stops failing database operations. */
431+
recoverDatabase(): this {
432+
this.nextStep();
433+
this.currentStep = {
434+
failDatabase: false
435+
};
436+
return this;
437+
}
438+
421439
expectIsShutdown(): this {
422440
this.assertStep('Active target expectation requires previous step');
423441
const currentStep = this.currentStep!;

0 commit comments

Comments
 (0)