Skip to content

Commit 58e1b6f

Browse files
Take RemoteStore offline during user change (#3193)
1 parent 71f94f1 commit 58e1b6f

File tree

6 files changed

+140
-79
lines changed

6 files changed

+140
-79
lines changed

packages/firestore/src/core/firestore_client.ts

+3-10
Original file line numberDiff line numberDiff line change
@@ -179,9 +179,9 @@ export class FirestoreClient {
179179
persistenceResult
180180
).then(initializationDone.resolve, initializationDone.reject);
181181
} else {
182-
this.asyncQueue.enqueueRetryable(() => {
183-
return this.handleCredentialChange(user);
184-
});
182+
this.asyncQueue.enqueueRetryable(() =>
183+
this.remoteStore.handleCredentialChange(user)
184+
);
185185
}
186186
});
187187

@@ -336,13 +336,6 @@ export class FirestoreClient {
336336
}
337337
}
338338

339-
private handleCredentialChange(user: User): Promise<void> {
340-
this.asyncQueue.verifyOperationInProgress();
341-
342-
logDebug(LOG_TAG, 'Credential Changed. Current user: ' + user.uid);
343-
return this.syncEngine.handleCredentialChange(user);
344-
}
345-
346339
/** Disables the network connection. Pending operations will not complete. */
347340
disableNetwork(): Promise<void> {
348341
this.verifyNotTerminated();

packages/firestore/src/core/sync_engine.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -865,6 +865,8 @@ export class SyncEngine implements RemoteSyncer {
865865
const userChanged = !this.currentUser.isEqual(user);
866866

867867
if (userChanged) {
868+
logDebug(LOG_TAG, 'User change. New user:', user.toKey());
869+
868870
const result = await this.localStore.handleUserChange(user);
869871
this.currentUser = user;
870872

@@ -880,8 +882,6 @@ export class SyncEngine implements RemoteSyncer {
880882
);
881883
await this.emitNewSnapsAndNotifyLocalStore(result.affectedDocuments);
882884
}
883-
884-
await this.remoteStore.handleCredentialChange();
885885
}
886886

887887
enableNetwork(): Promise<void> {

packages/firestore/src/remote/remote_store.ts

+49-33
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,29 @@ import {
5555
} from './watch_change';
5656
import { ByteString } from '../util/byte_string';
5757
import { isIndexedDbTransactionError } from '../local/simple_db';
58+
import { User } from '../auth/user';
5859

5960
const LOG_TAG = 'RemoteStore';
6061

6162
// TODO(b/35853402): Negotiate this with the stream.
6263
const MAX_PENDING_WRITES = 10;
6364

65+
/** Reasons for why the RemoteStore may be offline. */
66+
const enum OfflineCause {
67+
/** The user has explicitly disabled the network (via `disableNetwork()`). */
68+
UserDisabled,
69+
/** An IndexedDb failure occurred while persisting a stream update. */
70+
IndexedDbFailed,
71+
/** The tab is not the primary tab (only relevant with multi-tab). */
72+
IsSecondary,
73+
/** We are restarting the streams due to an Auth credential change. */
74+
CredentialChange,
75+
/** The connectivity state of the environment has changed. */
76+
ConnectivityChange,
77+
/** The RemoteStore has been shut down. */
78+
Shutdown
79+
}
80+
6481
/**
6582
* RemoteStore - An interface to remotely stored data, basically providing a
6683
* wrapper around the Datastore that is more reliable for the rest of the
@@ -117,19 +134,10 @@ export class RemoteStore implements TargetMetadataProvider {
117134
private watchChangeAggregator: WatchChangeAggregator | null = null;
118135

119136
/**
120-
* Set to true by enableNetwork() and false by disableNetwork() and indicates
121-
* the user-preferred network state.
137+
* A set of reasons for why the RemoteStore may be offline. If empty, the
138+
* RemoteStore may start its network connections.
122139
*/
123-
private networkEnabled = false;
124-
125-
private isPrimary = false;
126-
127-
/**
128-
* When set to `true`, the network was taken offline due to an IndexedDB
129-
* failure. The state is flipped to `false` when access becomes available
130-
* again.
131-
*/
132-
private indexedDbFailed = false;
140+
private offlineCauses = new Set<OfflineCause>();
133141

134142
private onlineStateTracker: OnlineStateTracker;
135143

@@ -193,7 +201,7 @@ export class RemoteStore implements TargetMetadataProvider {
193201

194202
/** Re-enables the network. Idempotent. */
195203
enableNetwork(): Promise<void> {
196-
this.networkEnabled = true;
204+
this.offlineCauses.delete(OfflineCause.UserDisabled);
197205
return this.enableNetworkInternal();
198206
}
199207

@@ -215,7 +223,7 @@ export class RemoteStore implements TargetMetadataProvider {
215223
* enableNetwork().
216224
*/
217225
async disableNetwork(): Promise<void> {
218-
this.networkEnabled = false;
226+
this.offlineCauses.add(OfflineCause.UserDisabled);
219227
await this.disableNetworkInternal();
220228

221229
// Set the OnlineState to Offline so get()s return from cache, etc.
@@ -239,7 +247,7 @@ export class RemoteStore implements TargetMetadataProvider {
239247

240248
async shutdown(): Promise<void> {
241249
logDebug(LOG_TAG, 'RemoteStore shutting down.');
242-
this.networkEnabled = false;
250+
this.offlineCauses.add(OfflineCause.Shutdown);
243251
await this.disableNetworkInternal();
244252
this.connectivityMonitor.shutdown();
245253

@@ -348,7 +356,7 @@ export class RemoteStore implements TargetMetadataProvider {
348356
}
349357

350358
canUseNetwork(): boolean {
351-
return !this.indexedDbFailed && this.isPrimary && this.networkEnabled;
359+
return this.offlineCauses.size === 0;
352360
}
353361

354362
private cleanUpWatchStreamState(): void {
@@ -456,10 +464,10 @@ export class RemoteStore implements TargetMetadataProvider {
456464
): Promise<void> {
457465
if (isIndexedDbTransactionError(e)) {
458466
debugAssert(
459-
!this.indexedDbFailed,
467+
!this.offlineCauses.has(OfflineCause.IndexedDbFailed),
460468
'Unexpected network event when IndexedDB was marked failed.'
461469
);
462-
this.indexedDbFailed = true;
470+
this.offlineCauses.add(OfflineCause.IndexedDbFailed);
463471

464472
// Disable network and raise offline snapshots
465473
await this.disableNetworkInternal();
@@ -476,7 +484,7 @@ export class RemoteStore implements TargetMetadataProvider {
476484
this.asyncQueue.enqueueRetryable(async () => {
477485
logDebug(LOG_TAG, 'Retrying IndexedDB access');
478486
await op!();
479-
this.indexedDbFailed = false;
487+
this.offlineCauses.delete(OfflineCause.IndexedDbFailed);
480488
await this.enableNetworkInternal();
481489
});
482490
} else {
@@ -750,31 +758,39 @@ export class RemoteStore implements TargetMetadataProvider {
750758
}
751759

752760
private async restartNetwork(): Promise<void> {
753-
this.networkEnabled = false;
761+
this.offlineCauses.add(OfflineCause.ConnectivityChange);
754762
await this.disableNetworkInternal();
755763
this.onlineStateTracker.set(OnlineState.Unknown);
756-
await this.enableNetwork();
764+
this.offlineCauses.delete(OfflineCause.ConnectivityChange);
765+
await this.enableNetworkInternal();
757766
}
758767

759-
async handleCredentialChange(): Promise<void> {
760-
if (this.canUseNetwork()) {
761-
// Tear down and re-create our network streams. This will ensure we get a fresh auth token
762-
// for the new user and re-fill the write pipeline with new mutations from the LocalStore
763-
// (since mutations are per-user).
764-
logDebug(LOG_TAG, 'RemoteStore restarting streams for new credential');
765-
await this.restartNetwork();
766-
}
768+
async handleCredentialChange(user: User): Promise<void> {
769+
this.asyncQueue.verifyOperationInProgress();
770+
771+
// Tear down and re-create our network streams. This will ensure we get a
772+
// fresh auth token for the new user and re-fill the write pipeline with
773+
// new mutations from the LocalStore (since mutations are per-user).
774+
logDebug(LOG_TAG, 'RemoteStore received new credentials');
775+
this.offlineCauses.add(OfflineCause.CredentialChange);
776+
777+
await this.disableNetworkInternal();
778+
this.onlineStateTracker.set(OnlineState.Unknown);
779+
await this.syncEngine.handleCredentialChange(user);
780+
781+
this.offlineCauses.delete(OfflineCause.CredentialChange);
782+
await this.enableNetworkInternal();
767783
}
768784

769785
/**
770786
* Toggles the network state when the client gains or loses its primary lease.
771787
*/
772788
async applyPrimaryState(isPrimary: boolean): Promise<void> {
773-
this.isPrimary = isPrimary;
774-
775-
if (isPrimary && this.networkEnabled) {
776-
await this.enableNetwork();
789+
if (isPrimary) {
790+
this.offlineCauses.delete(OfflineCause.IsSecondary);
791+
await this.enableNetworkInternal();
777792
} else if (!isPrimary) {
793+
this.offlineCauses.add(OfflineCause.IsSecondary);
778794
await this.disableNetworkInternal();
779795
this.onlineStateTracker.set(OnlineState.Unknown);
780796
}

packages/firestore/src/remote/remote_syncer.ts

+7
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { DocumentKeySet } from '../model/collections';
2020
import { MutationBatchResult } from '../model/mutation_batch';
2121
import { FirestoreError } from '../util/error';
2222
import { RemoteEvent } from './remote_event';
23+
import { User } from '../auth/user';
2324

2425
/**
2526
* An interface that describes the actions the RemoteStore needs to perform on
@@ -65,4 +66,10 @@ export interface RemoteSyncer {
6566
* the last snapshot.
6667
*/
6768
getRemoteKeysForTarget(targetId: TargetId): DocumentKeySet;
69+
70+
/**
71+
* Updates all local state to match the pending mutations for the given user.
72+
* May be called repeatedly for the same user.
73+
*/
74+
handleCredentialChange(user: User): Promise<void>;
6875
}

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

+73-23
Original file line numberDiff line numberDiff line change
@@ -721,29 +721,79 @@ describeSpec('Persistence Recovery', ['no-ios', 'no-android'], () => {
721721
{ foo: 'a' },
722722
{ hasLocalMutations: true }
723723
);
724-
return spec()
725-
.changeUser('user1')
726-
.userSets('collection/key1', { foo: 'a' })
727-
.userListens(query)
728-
.expectEvents(query, {
729-
added: [doc1],
730-
fromCache: true,
731-
hasPendingWrites: true
732-
})
733-
.failDatabaseTransactions('Handle user change')
734-
.changeUser('user2')
735-
.recoverDatabase()
736-
.runTimer(TimerId.AsyncQueueRetry)
737-
.expectEvents(query, { removed: [doc1], fromCache: true })
738-
.failDatabaseTransactions('Handle user change')
739-
.changeUser('user1')
740-
.recoverDatabase()
741-
.runTimer(TimerId.AsyncQueueRetry)
742-
.expectEvents(query, {
743-
added: [doc1],
744-
fromCache: true,
745-
hasPendingWrites: true
746-
});
724+
return (
725+
spec()
726+
.changeUser('user1')
727+
.userSets('collection/key1', { foo: 'a' })
728+
.userListens(query)
729+
.expectEvents(query, {
730+
added: [doc1],
731+
fromCache: true,
732+
hasPendingWrites: true
733+
})
734+
.failDatabaseTransactions('Handle user change')
735+
.changeUser('user2')
736+
// The network is offline due to the failed user change
737+
.expectActiveTargets()
738+
.recoverDatabase()
739+
.runTimer(TimerId.AsyncQueueRetry)
740+
.expectActiveTargets({ query })
741+
.expectEvents(query, { removed: [doc1], fromCache: true })
742+
.failDatabaseTransactions('Handle user change')
743+
.changeUser('user1')
744+
// The network is offline due to the failed user change
745+
.expectActiveTargets()
746+
.recoverDatabase()
747+
.runTimer(TimerId.AsyncQueueRetry)
748+
.expectActiveTargets({ query })
749+
.expectEvents(query, {
750+
added: [doc1],
751+
fromCache: true,
752+
hasPendingWrites: true
753+
})
754+
);
755+
}
756+
);
757+
758+
specTest(
759+
'Multiple user changes during transaction failure (with recovery)',
760+
['durable-persistence'],
761+
() => {
762+
const query = Query.atPath(path('collection'));
763+
const doc1 = doc(
764+
'collection/key1',
765+
0,
766+
{ foo: 'a' },
767+
{ hasLocalMutations: true }
768+
);
769+
return (
770+
spec()
771+
.changeUser('user1')
772+
.userSets('collection/key1', { foo: 'a' })
773+
.userListens(query)
774+
.expectEvents(query, {
775+
added: [doc1],
776+
fromCache: true,
777+
hasPendingWrites: true
778+
})
779+
// Change the user to user2 and back to user1 while IndexedDB is failed
780+
.failDatabaseTransactions('Handle user change')
781+
.changeUser('user2')
782+
// The network is offline due to the failed user change
783+
.expectActiveTargets()
784+
.changeUser('user1')
785+
.recoverDatabase()
786+
.runTimer(TimerId.AsyncQueueRetry)
787+
.expectActiveTargets({ query })
788+
// We are now user 2
789+
.expectEvents(query, { removed: [doc1], fromCache: true })
790+
// We are now user 1
791+
.expectEvents(query, {
792+
added: [doc1],
793+
fromCache: true,
794+
hasPendingWrites: true
795+
})
796+
);
747797
}
748798
);
749799
});

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

+6-11
Original file line numberDiff line numberDiff line change
@@ -726,17 +726,12 @@ abstract class TestRunner {
726726

727727
private async doChangeUser(user: string | null): Promise<void> {
728728
this.user = new User(user);
729-
const deferred = new Deferred<void>();
730-
await this.queue.enqueueRetryable(async () => {
731-
try {
732-
await this.syncEngine.handleCredentialChange(this.user);
733-
} finally {
734-
// Resolve the deferred Promise even if the operation failed. This allows
735-
// the spec tests to manually retry the failed user change.
736-
deferred.resolve();
737-
}
738-
});
739-
return deferred.promise;
729+
// We don't block on `handleCredentialChange` as it may not get executed
730+
// during an IndexedDb failure. Non-recovery tests will pick up the user
731+
// change when the AsyncQueue is drained.
732+
this.queue.enqueueRetryable(() =>
733+
this.remoteStore.handleCredentialChange(new User(user))
734+
);
740735
}
741736

742737
private async doFailDatabase(

0 commit comments

Comments
 (0)