Skip to content

Commit 0eaecd8

Browse files
authored
b/68276665: Raise isFromCache=true events when offline (#358)
* Plumbs OnlineState changes through to views. * View sets this.current to false on OnlineState.Failed, triggering isFromCache=true events. It will automatically be returned to true once the listen is reestablished and we get a new CURRENT message. * Updated tests (and added one new one) to verify behavior. * Unifies setOnlineStateToUnknown(), setOnlineStateToHealthy(), and updateAndBroadcastOnlineState() into a single updateOnlineState() method. * Split disableNetwork() into `public disableNetwork()` and `private disableNetworkInternal(targetOnlineState: OnlineState)`. * Some miscellaneous comment cleanup.
1 parent 39d1bff commit 0eaecd8

File tree

10 files changed

+179
-44
lines changed

10 files changed

+179
-44
lines changed

packages/firestore/CHANGELOG.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
# Unreleased
1+
# Unreleased (firestore-api-changes)
2+
- [changed] Snapshot listeners (with the `includeMetadataChanges` option
3+
enabled) now receive an event with `snapshot.metadata.fromCache` set to
4+
`true` if the SDK loses its connection to the backend. A new event with
5+
`snapshot.metadata.fromCache` set to false will be raised once the
6+
connection is restored and the query is in sync with the backend again.
7+
8+
# v0.2.0
29
- [feature] Added Node.js support for Cloud Firestore (with the exception of
310
the offline persistence feature).
411
- [changed] Webchannel requests use $httpHeaders URL parameter rather than

packages/firestore/src/core/firestore_client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,7 @@ export class FirestoreClient {
282282
);
283283

284284
const onlineStateChangedHandler = (onlineState: OnlineState) => {
285+
this.syncEngine.applyOnlineStateChange(onlineState);
285286
this.eventMgr.onOnlineStateChanged(onlineState);
286287
};
287288

packages/firestore/src/core/sync_engine.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ import { Query } from './query';
4242
import { SnapshotVersion } from './snapshot_version';
4343
import { TargetIdGenerator } from './target_id_generator';
4444
import { Transaction } from './transaction';
45-
import { BatchId, ProtoByteString, TargetId } from './types';
45+
import { BatchId, OnlineState, ProtoByteString, TargetId } from './types';
4646
import {
4747
AddedLimboDocument,
4848
LimboDocumentChange,
@@ -343,6 +343,25 @@ export class SyncEngine implements RemoteSyncer {
343343
});
344344
}
345345

346+
/**
347+
* Applies an OnlineState change to the sync engine and notifies any views of
348+
* the change.
349+
*/
350+
applyOnlineStateChange(onlineState: OnlineState) {
351+
const newViewSnapshots = [] as ViewSnapshot[];
352+
this.queryViewsByQuery.forEach((query, queryView) => {
353+
const viewChange = queryView.view.applyOnlineStateChange(onlineState);
354+
assert(
355+
viewChange.limboChanges.length === 0,
356+
'OnlineState should not affect limbo documents.'
357+
);
358+
if (viewChange.snapshot) {
359+
newViewSnapshots.push(viewChange.snapshot);
360+
}
361+
});
362+
this.viewHandler(newViewSnapshots);
363+
}
364+
346365
rejectListen(targetId: TargetId, err: FirestoreError): Promise<void> {
347366
this.assertSubscribed('rejectListens()');
348367
const limboKey = this.limboKeysByTarget[targetId];

packages/firestore/src/core/types.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,22 +34,22 @@ export type ProtoByteString = Uint8Array | string;
3434
export enum OnlineState {
3535
/**
3636
* The Firestore client is in an unknown online state. This means the client
37-
* is either not actively trying to establish a connection or it was
38-
* previously in an unknown state and is trying to establish a connection.
37+
* is either not actively trying to establish a connection or it is currently
38+
* trying to establish a connection, but it has not succeeded or failed yet.
3939
*/
4040
Unknown,
4141

4242
/**
4343
* The client is connected and the connections are healthy. This state is
4444
* reached after a successful connection and there has been at least one
45-
* succesful message received from the backends.
45+
* successful message received from the backends.
4646
*/
4747
Healthy,
4848

4949
/**
50-
* The client has tried to establish a connection but has failed.
51-
* This state is reached after either a connection attempt failed or a
52-
* healthy stream was closed for unexpected reasons.
50+
* The client considers itself offline. It is either trying to establish a
51+
* connection but failing, or it has been explicitly marked offline via a call
52+
* to disableNetwork().
5353
*/
5454
Failed
5555
}

packages/firestore/src/core/view.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
import { assert, fail } from '../util/assert';
3232

3333
import { Query } from './query';
34+
import { OnlineState } from './types';
3435
import {
3536
ChangeType,
3637
DocumentChangeSet,
@@ -50,7 +51,7 @@ export class RemovedLimboDocument {
5051
export interface ViewDocumentChanges {
5152
/** The new set of docs that should be in the view. */
5253
documentSet: DocumentSet;
53-
/** The diff of this these docs with the previous set of docs. */
54+
/** The diff of these docs with the previous set of docs. */
5455
changeSet: DocumentChangeSet;
5556
/**
5657
* Whether the set of documents passed in was not sufficient to calculate the
@@ -268,6 +269,29 @@ export class View {
268269
}
269270
}
270271

272+
/**
273+
* Applies an OnlineState change to the view, potentially generating a
274+
* ViewChange if the view's syncState changes as a result.
275+
*/
276+
applyOnlineStateChange(onlineState: OnlineState): ViewChange {
277+
if (this.current && onlineState === OnlineState.Failed) {
278+
// If we're offline, set `current` to false and then call applyChanges()
279+
// to refresh our syncState and generate a ViewChange as appropriate. We
280+
// are guaranteed to get a new TargetChange that sets `current` back to
281+
// true once the client is back online.
282+
this.current = false;
283+
return this.applyChanges({
284+
documentSet: this.documentSet,
285+
changeSet: new DocumentChangeSet(),
286+
mutatedKeys: this.mutatedKeys,
287+
needsRefill: false
288+
});
289+
} else {
290+
// No effect, just return a no-op ViewChange.
291+
return { limboChanges: [] };
292+
}
293+
}
294+
271295
/**
272296
* Returns whether the doc for the given key should be in limbo.
273297
*/

packages/firestore/src/remote/remote_store.ts

Lines changed: 54 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -153,48 +153,53 @@ export class RemoteStore {
153153
return this.enableNetwork();
154154
}
155155

156-
private setOnlineStateToHealthy(): void {
157-
this.shouldWarnOffline = false;
158-
this.updateAndBroadcastOnlineState(OnlineState.Healthy);
159-
}
156+
/**
157+
* Updates our OnlineState to the new state, updating local state
158+
* and notifying the onlineStateHandler as appropriate.
159+
*/
160+
private updateOnlineState(newState: OnlineState) {
161+
if (newState === OnlineState.Healthy) {
162+
// We've connected to watch at least once. Don't warn the developer about
163+
// being offline going forward.
164+
this.shouldWarnOffline = false;
165+
} else if (newState === OnlineState.Unknown) {
166+
// The state is set to unknown when a healthy stream is closed (e.g. due to
167+
// a token timeout) or when we have no active listens and therefore there's
168+
// no need to start the stream. Assuming there is (possibly in the future)
169+
// an active listen, then we will eventually move to state Online or Failed,
170+
// but we always want to make at least ONLINE_ATTEMPTS_BEFORE_FAILURE
171+
// attempts before failing, so we reset the count here.
172+
this.watchStreamFailures = 0;
173+
}
160174

161-
private setOnlineStateToUnknown(): void {
162-
// The state is set to unknown when a healthy stream is closed (e.g. due to
163-
// a token timeout) or when we have no active listens and therefore there's
164-
// no need to start the stream. Assuming there is (possibly in the future)
165-
// an active listen, then we will eventually move to state Online or Failed,
166-
// but we always want to make at least ONLINE_ATTEMPTS_BEFORE_FAILURE
167-
// attempts before failing, so we reset the count here.
168-
this.watchStreamFailures = 0;
169-
this.updateAndBroadcastOnlineState(OnlineState.Unknown);
175+
// Update and broadcast the new state.
176+
if (newState !== this.watchStreamOnlineState) {
177+
this.watchStreamOnlineState = newState;
178+
this.onlineStateHandler(newState);
179+
}
170180
}
171181

182+
/**
183+
* Updates our OnlineState as appropriate after the watch stream reports a
184+
* failure. The first failure moves us to the 'Unknown' state. We then may
185+
* allow multiple failures (based on ONLINE_ATTEMPTS_BEFORE_FAILURE) before we
186+
* actually transition to OnlineState.Failed.
187+
*/
172188
private updateOnlineStateAfterFailure(): void {
173-
// The first failure after we are successfully connected moves us to the
174-
// 'Unknown' state. We then may make multiple attempts (based on
175-
// ONLINE_ATTEMPTS_BEFORE_FAILURE) before we actually report failure.
176189
if (this.watchStreamOnlineState === OnlineState.Healthy) {
177-
this.setOnlineStateToUnknown();
190+
this.updateOnlineState(OnlineState.Unknown);
178191
} else {
179192
this.watchStreamFailures++;
180193
if (this.watchStreamFailures >= ONLINE_ATTEMPTS_BEFORE_FAILURE) {
181194
if (this.shouldWarnOffline) {
182195
log.debug(LOG_TAG, 'Could not reach Firestore backend.');
183196
this.shouldWarnOffline = false;
184197
}
185-
this.updateAndBroadcastOnlineState(OnlineState.Failed);
198+
this.updateOnlineState(OnlineState.Failed);
186199
}
187200
}
188201
}
189202

190-
private updateAndBroadcastOnlineState(onlineState: OnlineState): void {
191-
const didChange = this.watchStreamOnlineState !== onlineState;
192-
this.watchStreamOnlineState = onlineState;
193-
if (didChange) {
194-
this.onlineStateHandler(onlineState);
195-
}
196-
}
197-
198203
private isNetworkEnabled(): boolean {
199204
assert(
200205
(this.watchStream == null) == (this.writeStream == null),
@@ -226,16 +231,28 @@ export class RemoteStore {
226231
this.startWatchStream();
227232
}
228233

229-
this.updateAndBroadcastOnlineState(OnlineState.Unknown);
234+
this.updateOnlineState(OnlineState.Unknown);
230235

231236
return this.fillWritePipeline(); // This may start the writeStream.
232237
});
233238
}
234239

235-
/** Temporarily disables the network. The network can be re-enabled using enableNetwork(). */
240+
/**
241+
* Temporarily disables the network. The network can be re-enabled using
242+
* enableNetwork().
243+
*/
236244
disableNetwork(): Promise<void> {
237-
this.updateAndBroadcastOnlineState(OnlineState.Failed);
245+
// Set the OnlineState to failed so get()'s return from cache, etc.
246+
return this.disableNetworkInternal(OnlineState.Failed);
247+
}
238248

249+
/**
250+
* Disables the network, setting the OnlineState to the specified
251+
* targetOnlineState.
252+
*/
253+
private disableNetworkInternal(
254+
targetOnlineState: OnlineState
255+
): Promise<void> {
239256
// NOTE: We're guaranteed not to get any further events from these streams (not even a close
240257
// event).
241258
this.watchStream.stop();
@@ -247,12 +264,16 @@ export class RemoteStore {
247264
this.writeStream = null;
248265
this.watchStream = null;
249266

267+
this.updateOnlineState(targetOnlineState);
268+
250269
return Promise.resolve();
251270
}
252271

253272
shutdown(): Promise<void> {
254273
log.debug(LOG_TAG, 'RemoteStore shutting down.');
255-
this.disableNetwork();
274+
// Set the OnlineState to Unknown (rather than Failed) to avoid potentially
275+
// triggering spurious listener events with cached data, etc.
276+
this.disableNetworkInternal(OnlineState.Unknown);
256277
return Promise.resolve(undefined);
257278
}
258279

@@ -376,7 +397,7 @@ export class RemoteStore {
376397
// No need to restart watch stream because there are no active targets.
377398
// The online state is set to unknown because there is no active attempt
378399
// at establishing a connection
379-
this.setOnlineStateToUnknown();
400+
this.updateOnlineState(OnlineState.Unknown);
380401
}
381402
return Promise.resolve();
382403
}
@@ -386,7 +407,7 @@ export class RemoteStore {
386407
snapshotVersion: SnapshotVersion
387408
): Promise<void> {
388409
// Mark the connection as healthy because we got a message from the server
389-
this.setOnlineStateToHealthy();
410+
this.updateOnlineState(OnlineState.Healthy);
390411

391412
if (
392413
watchChange instanceof WatchTargetChange &&
@@ -799,7 +820,7 @@ export class RemoteStore {
799820
// Tear down and re-create our network streams. This will ensure we get a fresh auth token
800821
// for the new user and re-fill the write pipeline with new mutations from the LocalStore
801822
// (since mutations are per-user).
802-
this.disableNetwork();
823+
this.disableNetworkInternal(OnlineState.Unknown);
803824
return this.enableNetwork();
804825
}
805826
}

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,4 +458,41 @@ apiDescribe('Queries', persistence => {
458458
return deferred.promise.then(unregister);
459459
});
460460
});
461+
462+
it('Queries trigger with isFromCache=true when offline', () => {
463+
return withTestCollection(persistence, { a: { foo: 1 } }, coll => {
464+
// TODO(mikelehen): Find better way to expose this to tests.
465+
// tslint:disable-next-line:no-any enableNetwork isn't exposed via d.ts
466+
const firestoreInternal = coll.firestore.INTERNAL as any;
467+
468+
const accum = new EventsAccumulator<firestore.QuerySnapshot>();
469+
const unregister = coll.onSnapshot(
470+
{ includeQueryMetadataChanges: true },
471+
accum.storeEvent
472+
);
473+
474+
return accum
475+
.awaitEvent()
476+
.then(querySnap => {
477+
// initial event
478+
expect(querySnap.docs.map(doc => doc.data())).to.deep.equal([
479+
{ foo: 1 }
480+
]);
481+
expect(querySnap.metadata.fromCache).to.be.false;
482+
})
483+
.then(() => firestoreInternal.disableNetwork())
484+
.then(() => accum.awaitEvent())
485+
.then(querySnap => {
486+
// offline event with fromCache = true
487+
expect(querySnap.metadata.fromCache).to.be.true;
488+
})
489+
.then(() => firestoreInternal.enableNetwork())
490+
.then(() => accum.awaitEvent())
491+
.then(querySnap => {
492+
// back online event with fromCache = false
493+
expect(querySnap.metadata.fromCache).to.be.false;
494+
unregister();
495+
});
496+
});
497+
});
461498
});

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,7 @@ describeSpec('Listens:', [], () => {
253253
.watchAcksFull(query, 1000, docA)
254254
.expectEvents(query, { added: [docA] })
255255
.disableNetwork()
256+
.expectEvents(query, { fromCache: true })
256257
.enableNetwork()
257258
.restoreListen(query, 'resume-token-1000')
258259
.expectWatchStreamRequestCount(

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

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import { expect } from 'chai';
1818
import { Query } from '../../../src/core/query';
1919
import { Code } from '../../../src/util/error';
20-
import { path } from '../../util/helpers';
20+
import { doc, path } from '../../util/helpers';
2121

2222
import { describeSpec, specTest } from './describe_spec';
2323
import { spec } from './spec_builder';
@@ -94,4 +94,25 @@ describeSpec('Offline:', [], () => {
9494
);
9595
}
9696
);
97+
98+
specTest('Queries revert to fromCache=true when offline.', [], () => {
99+
const query = Query.atPath(path('collection'));
100+
const docA = doc('collection/a', 1000, { key: 'a' });
101+
return (
102+
spec()
103+
.userListens(query)
104+
.watchAcksFull(query, 1000, docA)
105+
.expectEvents(query, { added: [docA] })
106+
// first error triggers unknown state
107+
.watchStreamCloses(Code.UNAVAILABLE)
108+
.restoreListen(query, 'resume-token-1000')
109+
// getting two more errors triggers offline state and fromCache: true
110+
.watchStreamCloses(Code.UNAVAILABLE)
111+
.watchStreamCloses(Code.UNAVAILABLE)
112+
.expectEvents(query, { fromCache: true })
113+
// Going online and getting a CURRENT message triggers fromCache: false
114+
.watchAcksFull(query, 1000)
115+
.expectEvents(query, { fromCache: false })
116+
);
117+
});
97118
});

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,7 @@ abstract class TestRunner {
384384
initialBackoffDelay
385385
);
386386
const onlineStateChangedHandler = (onlineState: OnlineState) => {
387+
this.syncEngine.applyOnlineStateChange(onlineState);
387388
this.eventManager.onOnlineStateChanged(onlineState);
388389
};
389390
this.remoteStore = new RemoteStore(
@@ -498,7 +499,10 @@ abstract class TestRunner {
498499

499500
await this.queue.schedule(async () => {
500501
const targetId = await this.eventManager.listen(queryListener);
501-
expect(targetId).to.equal(expectedTargetId);
502+
expect(targetId).to.equal(
503+
expectedTargetId,
504+
'targetId assigned to listen'
505+
);
502506
});
503507
// Open should always have happened after a listen
504508
await this.connection.waitForWatchOpen();

0 commit comments

Comments
 (0)