Skip to content

Commit 6e8e2b4

Browse files
Make shutdown idempotent
1 parent 36be62a commit 6e8e2b4

File tree

10 files changed

+98
-74
lines changed

10 files changed

+98
-74
lines changed

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

Lines changed: 25 additions & 11 deletions
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.initiateShutdown();
137+
const deferred = new Deferred();
138+
this._queue.enqueueAndForgetEvenAfterShutdown(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

packages/firestore/src/api/credentials.ts

Lines changed: 0 additions & 13 deletions
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/core/firestore_client.ts

Lines changed: 25 additions & 13 deletions
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.initiateShutdown();
367+
const deferred = new Deferred();
368+
this.asyncQueue.enqueueAndForgetEvenAfterShutdown(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

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -684,8 +684,6 @@ export class IndexedDbPersistence implements Persistence {
684684
return this.releasePrimaryLeaseIfHeld(txn).next(() =>
685685
this.removeClientMetadata(txn)
686686
);
687-
}).catch(e => {
688-
logDebug(LOG_TAG, 'Proceeding with shutdown despite failure: ', e);
689687
});
690688
this.simpleDb.close();
691689

packages/firestore/src/util/async_queue.ts

Lines changed: 4 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -272,32 +272,17 @@ export class AsyncQueue {
272272
}
273273

274274
/**
275-
* Regardless if the queue has initialized shutdown, adds a new operation to the
276-
* queue.
275+
* Initialize the shutdown of this queue. Once this method is called, the
276+
* only possible way to request running an operation is through
277+
* `enqueueEvenAfterShutdown`.
277278
*/
278-
private enqueueEvenAfterShutdown<T extends unknown>(
279-
op: () => Promise<T>
280-
): Promise<T> {
281-
this.verifyNotFailed();
282-
return this.enqueueInternal(op);
283-
}
284-
285-
/**
286-
* Adds a new operation to the queue and initialize the shut down of this queue.
287-
* Returns a promise that will be resolved when the promise returned by the new
288-
* operation is (with its value).
289-
* Once this method is called, the only possible way to request running an operation
290-
* is through `enqueueAndForgetEvenAfterShutdown`.
291-
*/
292-
async enqueueAndInitiateShutdown(op: () => Promise<void>): Promise<void> {
293-
this.verifyNotFailed();
279+
initiateShutdown(): void {
294280
if (!this._isShuttingDown) {
295281
this._isShuttingDown = true;
296282
const window = getWindow();
297283
if (window) {
298284
window.removeEventListener('visibilitychange', this.visibilityHandler);
299285
}
300-
await this.enqueueEvenAfterShutdown(op);
301286
}
302287
}
303288

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

Lines changed: 7 additions & 0 deletions
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/unit/specs/recovery_spec.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -840,4 +840,12 @@ describeSpec('Persistence Recovery', ['no-ios', 'no-android'], () => {
840840
})
841841
.userUnlistens(query1);
842842
});
843+
844+
specTest('Terminate (with recovery)', [], () => {
845+
return spec()
846+
.failDatabaseTransactions('shutdown')
847+
.shutdown({ expectFailure: true })
848+
.recoverDatabase()
849+
.shutdown();
850+
});
843851
});

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -436,10 +436,10 @@ export class SpecBuilder {
436436
return this;
437437
}
438438

439-
shutdown(): this {
439+
shutdown(options?: { expectFailure?: boolean }): this {
440440
this.nextStep();
441441
this.currentStep = {
442-
shutdown: true,
442+
shutdown: options ?? true,
443443
expectedState: {
444444
activeTargets: {},
445445
activeLimboDocs: [],

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

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -296,11 +296,15 @@ abstract class TestRunner {
296296
}
297297

298298
async shutdown(): Promise<void> {
299-
await this.queue.enqueueAndInitiateShutdown(async () => {
299+
this.queue.initiateShutdown();
300+
const deferred = new Deferred();
301+
this.queue.enqueueAndForgetEvenAfterShutdown(async () => {
300302
if (this.started) {
301303
await this.doShutdown();
302304
}
305+
deferred.resolve();
303306
});
307+
return deferred.promise;
304308
}
305309

306310
/** Runs a single SpecStep on this runner. */
@@ -363,7 +367,9 @@ abstract class TestRunner {
363367
} else if ('restart' in step) {
364368
return this.doRestart();
365369
} else if ('shutdown' in step) {
366-
return this.doShutdown();
370+
return typeof step.shutdown === 'object'
371+
? this.doShutdown(step.shutdown)
372+
: this.doShutdown();
367373
} else if ('applyClientState' in step) {
368374
// PORTING NOTE: Only used by web multi-tab tests.
369375
return this.doApplyClientState(step.applyClientState!);
@@ -707,14 +713,20 @@ abstract class TestRunner {
707713
await this.remoteStore.enableNetwork();
708714
}
709715

710-
private async doShutdown(): Promise<void> {
711-
await this.remoteStore.shutdown();
712-
await this.sharedClientState.shutdown();
713-
// We don't delete the persisted data here since multi-clients may still
714-
// be accessing it. Instead, we manually remove it at the end of the
715-
// test run.
716-
await this.persistence.shutdown();
717-
this.started = false;
716+
private async doShutdown(options?: {expectFailure?: boolean}): Promise<void> {
717+
try {
718+
await this.remoteStore.shutdown();
719+
await this.sharedClientState.shutdown();
720+
// We don't delete the persisted data here since multi-clients may still
721+
// be accessing it. Instead, we manually remove it at the end of the
722+
// test run.
723+
await this.persistence.shutdown();
724+
expect(options?.expectFailure).to.not.be.true;
725+
726+
this.started = false;
727+
} catch (e) {
728+
expect(options?.expectFailure).to.be.true;
729+
}
718730
}
719731

720732
private async doClearPersistence(): Promise<void> {
@@ -1253,7 +1265,8 @@ export type PersistenceAction =
12531265
| 'Get new document changes'
12541266
| 'Synchronize last document change read time'
12551267
| 'updateClientMetadataAndTryBecomePrimary'
1256-
| 'getHighestListenSequenceNumber';
1268+
| 'getHighestListenSequenceNumber'
1269+
| 'shutdown';
12571270

12581271
/**
12591272
* Union type for each step. The step consists of exactly one `field`
@@ -1333,7 +1346,7 @@ export interface SpecStep {
13331346
restart?: true;
13341347

13351348
/** Shut down the client and close it network connection. */
1336-
shutdown?: true;
1349+
shutdown?: true | { expectFailure?: boolean };
13371350

13381351
/**
13391352
* Optional list of expected events.

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -397,8 +397,8 @@ describe('AsyncQueue', () => {
397397

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

0 commit comments

Comments
 (0)