Skip to content

Commit 960093d

Browse files
Make shutdown idempotent (#3575)
1 parent 980c7d5 commit 960093d

File tree

12 files changed

+95
-74
lines changed

12 files changed

+95
-74
lines changed

.changeset/neat-gorillas-behave.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@firebase/firestore": patch
3+
---
4+
5+
`terminate()` can now be retried if it fails with an IndexedDB exception.

packages/firestore/exp/src/api/database.ts

+26-12
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ import {
2727
enqueueWaitForPendingWrites,
2828
MAX_CONCURRENT_LIMBO_RESOLUTIONS
2929
} from '../../../src/core/firestore_client';
30-
import { AsyncQueue } from '../../../src/util/async_queue';
30+
import {
31+
AsyncQueue,
32+
wrapInUserErrorIfRecoverable
33+
} from '../../../src/util/async_queue';
3134
import {
3235
ComponentConfiguration,
3336
IndexedDbOfflineComponentProvider,
@@ -59,7 +62,6 @@ import { AutoId } from '../../../src/util/misc';
5962
import { User } from '../../../src/auth/user';
6063
import { CredentialChangeListener } from '../../../src/api/credentials';
6164
import { logDebug } from '../../../src/util/log';
62-
import { debugAssert } from '../../../src/util/assert';
6365

6466
const LOG_TAG = 'Firestore';
6567

@@ -131,16 +133,28 @@ export class Firestore extends LiteFirestore
131133
}
132134

133135
_terminate(): Promise<void> {
134-
debugAssert(!this._terminated, 'Cannot invoke _terminate() more than once');
135-
return this._queue.enqueueAndInitiateShutdown(async () => {
136-
await super._terminate();
137-
await removeComponents(this);
138-
139-
// `removeChangeListener` must be called after shutting down the
140-
// RemoteStore as it will prevent the RemoteStore from retrieving
141-
// auth tokens.
142-
this._credentials.removeChangeListener();
136+
this._queue.enterRestrictedMode();
137+
const deferred = new Deferred();
138+
this._queue.enqueueAndForgetEvenWhileRestricted(async () => {
139+
try {
140+
await super._terminate();
141+
await removeComponents(this);
142+
143+
// `removeChangeListener` must be called after shutting down the
144+
// RemoteStore as it will prevent the RemoteStore from retrieving
145+
// auth tokens.
146+
this._credentials.removeChangeListener();
147+
148+
deferred.resolve();
149+
} catch (e) {
150+
const firestoreError = wrapInUserErrorIfRecoverable(
151+
e,
152+
`Failed to shutdown persistence`
153+
);
154+
deferred.reject(firestoreError);
155+
}
143156
});
157+
return deferred.promise;
144158
}
145159
}
146160

@@ -242,7 +256,7 @@ export function clearIndexedDbPersistence(
242256
}
243257

244258
const deferred = new Deferred<void>();
245-
firestoreImpl._queue.enqueueAndForgetEvenAfterShutdown(async () => {
259+
firestoreImpl._queue.enqueueAndForgetEvenWhileRestricted(async () => {
246260
try {
247261
await indexedDbClearPersistence(
248262
indexedDbStoragePrefix(

packages/firestore/src/api/credentials.ts

-13
Original file line numberDiff line numberDiff line change
@@ -127,10 +127,6 @@ export class EmptyCredentialsProvider implements CredentialsProvider {
127127
}
128128

129129
removeChangeListener(): void {
130-
debugAssert(
131-
this.changeListener !== null,
132-
'removeChangeListener() when no listener registered'
133-
);
134130
this.changeListener = null;
135131
}
136132
}
@@ -252,15 +248,6 @@ export class FirebaseCredentialsProvider implements CredentialsProvider {
252248
}
253249

254250
removeChangeListener(): void {
255-
debugAssert(
256-
this.tokenListener != null,
257-
'removeChangeListener() called twice'
258-
);
259-
debugAssert(
260-
this.changeListener !== null,
261-
'removeChangeListener() called when no listener registered'
262-
);
263-
264251
if (this.auth) {
265252
this.auth.removeAuthTokenListener(this.tokenListener!);
266253
}

packages/firestore/src/api/database.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -448,7 +448,7 @@ export class Firestore implements firestore.FirebaseFirestore, FirebaseService {
448448
}
449449

450450
const deferred = new Deferred<void>();
451-
this._queue.enqueueAndForgetEvenAfterShutdown(async () => {
451+
this._queue.enqueueAndForgetEvenWhileRestricted(async () => {
452452
try {
453453
await this._offlineComponentProvider.clearPersistence(
454454
this._databaseId,

packages/firestore/src/core/firestore_client.ts

+25-13
Original file line numberDiff line numberDiff line change
@@ -363,21 +363,33 @@ export class FirestoreClient {
363363
}
364364

365365
terminate(): Promise<void> {
366-
return this.asyncQueue.enqueueAndInitiateShutdown(async () => {
367-
// PORTING NOTE: LocalStore does not need an explicit shutdown on web.
368-
if (this.gcScheduler) {
369-
this.gcScheduler.stop();
370-
}
371-
372-
await this.remoteStore.shutdown();
373-
await this.sharedClientState.shutdown();
374-
await this.persistence.shutdown();
366+
this.asyncQueue.enterRestrictedMode();
367+
const deferred = new Deferred();
368+
this.asyncQueue.enqueueAndForgetEvenWhileRestricted(async () => {
369+
try {
370+
// PORTING NOTE: LocalStore does not need an explicit shutdown on web.
371+
if (this.gcScheduler) {
372+
this.gcScheduler.stop();
373+
}
375374

376-
// `removeChangeListener` must be called after shutting down the
377-
// RemoteStore as it will prevent the RemoteStore from retrieving
378-
// auth tokens.
379-
this.credentials.removeChangeListener();
375+
await this.remoteStore.shutdown();
376+
await this.sharedClientState.shutdown();
377+
await this.persistence.shutdown();
378+
379+
// `removeChangeListener` must be called after shutting down the
380+
// RemoteStore as it will prevent the RemoteStore from retrieving
381+
// auth tokens.
382+
this.credentials.removeChangeListener();
383+
deferred.resolve();
384+
} catch (e) {
385+
const firestoreError = wrapInUserErrorIfRecoverable(
386+
e,
387+
`Failed to shutdown persistence`
388+
);
389+
deferred.reject(firestoreError);
390+
}
380391
});
392+
return deferred.promise;
381393
}
382394

383395
/**

packages/firestore/src/local/indexeddb_persistence.ts

+16-7
Original file line numberDiff line numberDiff line change
@@ -680,13 +680,22 @@ export class IndexedDbPersistence implements Persistence {
680680
}
681681
this.detachVisibilityHandler();
682682
this.detachWindowUnloadHook();
683-
await this.runTransaction('shutdown', 'readwrite', txn => {
684-
return this.releasePrimaryLeaseIfHeld(txn).next(() =>
685-
this.removeClientMetadata(txn)
686-
);
687-
}).catch(e => {
688-
logDebug(LOG_TAG, 'Proceeding with shutdown despite failure: ', e);
689-
});
683+
684+
// Use `SimpleDb.runTransaction` directly to avoid failing if another tab
685+
// has obtained the primary lease.
686+
await this.simpleDb.runTransaction(
687+
'readwrite',
688+
[DbPrimaryClient.store, DbClientMetadata.store],
689+
simpleDbTxn => {
690+
const persistenceTransaction = new IndexedDbTransaction(
691+
simpleDbTxn,
692+
ListenSequence.INVALID
693+
);
694+
return this.releasePrimaryLeaseIfHeld(persistenceTransaction).next(() =>
695+
this.removeClientMetadata(persistenceTransaction)
696+
);
697+
}
698+
);
690699
this.simpleDb.close();
691700

692701
// Remove the entry marking the client as zombied from LocalStorage since

packages/firestore/src/util/async_queue.ts

+5-20
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ export class AsyncQueue {
273273
* Regardless if the queue has initialized shutdown, adds a new operation to the
274274
* queue without waiting for it to complete (i.e. we ignore the Promise result).
275275
*/
276-
enqueueAndForgetEvenAfterShutdown<T extends unknown>(
276+
enqueueAndForgetEvenWhileRestricted<T extends unknown>(
277277
op: () => Promise<T>
278278
): void {
279279
this.verifyNotFailed();
@@ -282,25 +282,11 @@ export class AsyncQueue {
282282
}
283283

284284
/**
285-
* Regardless if the queue has initialized shutdown, adds a new operation to the
286-
* queue.
285+
* Initialize the shutdown of this queue. Once this method is called, the
286+
* only possible way to request running an operation is through
287+
* `enqueueEvenWhileRestricted()`.
287288
*/
288-
private enqueueEvenAfterShutdown<T extends unknown>(
289-
op: () => Promise<T>
290-
): Promise<T> {
291-
this.verifyNotFailed();
292-
return this.enqueueInternal(op);
293-
}
294-
295-
/**
296-
* Adds a new operation to the queue and initialize the shut down of this queue.
297-
* Returns a promise that will be resolved when the promise returned by the new
298-
* operation is (with its value).
299-
* Once this method is called, the only possible way to request running an operation
300-
* is through `enqueueAndForgetEvenAfterShutdown`.
301-
*/
302-
async enqueueAndInitiateShutdown(op: () => Promise<void>): Promise<void> {
303-
this.verifyNotFailed();
289+
enterRestrictedMode(): void {
304290
if (!this._isShuttingDown) {
305291
this._isShuttingDown = true;
306292
const document = getDocument();
@@ -310,7 +296,6 @@ export class AsyncQueue {
310296
this.visibilityHandler
311297
);
312298
}
313-
await this.enqueueEvenAfterShutdown(op);
314299
}
315300
}
316301

packages/firestore/test/integration/api/database.test.ts

+7
Original file line numberDiff line numberDiff line change
@@ -1079,6 +1079,13 @@ apiDescribe('Database', (persistence: boolean) => {
10791079
});
10801080
});
10811081

1082+
it('can call terminate() multiple times', async () => {
1083+
return withTestDb(persistence, async db => {
1084+
await db.terminate();
1085+
await db.terminate();
1086+
});
1087+
});
1088+
10821089
// eslint-disable-next-line no-restricted-properties
10831090
(MEMORY_ONLY_BUILD ? it : it.skip)(
10841091
'recovers when persistence is missing',

packages/firestore/test/integration/util/firebase_export.ts

-3
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,6 @@ export function newTestFirestore(
8080
: getFirestore(app);
8181
return new FirestoreShim(firestore);
8282
} else {
83-
if (nameOrApp === undefined) {
84-
nameOrApp = 'test-app-' + appCount++;
85-
}
8683
const app =
8784
typeof nameOrApp === 'string'
8885
? firebase.initializeApp(

packages/firestore/test/unit/local/indexeddb_persistence.test.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1182,6 +1182,7 @@ describe('IndexedDb: allowTabSynchronization', () => {
11821182
await expect(db1.start()).to.eventually.be.rejectedWith(
11831183
'IndexedDB transaction failed'
11841184
);
1185+
await db1.shutdown();
11851186
}
11861187
);
11871188
});

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

+5-1
Original file line numberDiff line numberDiff line change
@@ -299,11 +299,15 @@ abstract class TestRunner {
299299
}
300300

301301
async shutdown(): Promise<void> {
302-
await this.queue.enqueueAndInitiateShutdown(async () => {
302+
this.queue.enterRestrictedMode();
303+
const deferred = new Deferred();
304+
this.queue.enqueueAndForgetEvenWhileRestricted(async () => {
303305
if (this.started) {
304306
await this.doShutdown();
305307
}
308+
deferred.resolve();
306309
});
310+
return deferred.promise;
307311
}
308312

309313
/** Runs a single SpecStep on this runner. */

packages/firestore/test/unit/util/async_queue.test.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -396,11 +396,11 @@ describe('AsyncQueue', () => {
396396
queue.enqueueAndForget(() => doStep(1));
397397

398398
// After this call, only operations requested via
399-
// `enqueueAndForgetEvenAfterShutdown` gets executed.
400-
// eslint-disable-next-line @typescript-eslint/no-floating-promises
401-
queue.enqueueAndInitiateShutdown(() => doStep(2));
399+
// `enqueueAndForgetEvenWhileRestricted` gets executed.
400+
queue.enterRestrictedMode();
401+
queue.enqueueAndForgetEvenWhileRestricted(() => doStep(2));
402402
queue.enqueueAndForget(() => doStep(3));
403-
queue.enqueueAndForgetEvenAfterShutdown(() => doStep(4));
403+
queue.enqueueAndForgetEvenWhileRestricted(() => doStep(4));
404404

405405
await queue.drain();
406406
expect(completedSteps).to.deep.equal([1, 2, 4]);

0 commit comments

Comments
 (0)