Skip to content

Commit ce88e71

Browse files
authored
snapshot listeners source from cache (#7982)
1 parent 6d487d7 commit ce88e71

17 files changed

+2055
-81
lines changed

.changeset/smart-games-cheer.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@firebase/firestore': minor
3+
'firebase': minor
4+
---
5+
Enable snapshot listener option to retrieve data from local cache only.

common/api-review/firestore.api.md

+4
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,9 @@ export function limit(limit: number): QueryLimitConstraint;
350350
// @public
351351
export function limitToLast(limit: number): QueryLimitConstraint;
352352

353+
// @public
354+
export type ListenSource = 'default' | 'cache';
355+
353356
// @public
354357
export function loadBundle(firestore: Firestore, bundleData: ReadableStream<Uint8Array> | ArrayBuffer | string): LoadBundleTask;
355358

@@ -651,6 +654,7 @@ export function snapshotEqual<AppModelType, DbModelType extends DocumentData>(le
651654
// @public
652655
export interface SnapshotListenOptions {
653656
readonly includeMetadataChanges?: boolean;
657+
readonly source?: ListenSource;
654658
}
655659

656660
// @public

docs-devsite/firestore_.md

+13
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ https://github.com/firebase/firebase-js-sdk
204204
| [DocumentChangeType](./firestore_.md#documentchangetype) | The type of a <code>DocumentChange</code> may be 'added', 'removed', or 'modified'. |
205205
| [FirestoreErrorCode](./firestore_.md#firestoreerrorcode) | The set of Firestore status codes. The codes are the same at the ones exposed by gRPC here: https://github.com/grpc/grpc/blob/master/doc/statuscodes.md<!-- -->Possible values: - 'cancelled': The operation was cancelled (typically by the caller). - 'unknown': Unknown error or an error from a different error domain. - 'invalid-argument': Client specified an invalid argument. Note that this differs from 'failed-precondition'. 'invalid-argument' indicates arguments that are problematic regardless of the state of the system (e.g. an invalid field name). - 'deadline-exceeded': Deadline expired before operation could complete. For operations that change the state of the system, this error may be returned even if the operation has completed successfully. For example, a successful response from a server could have been delayed long enough for the deadline to expire. - 'not-found': Some requested document was not found. - 'already-exists': Some document that we attempted to create already exists. - 'permission-denied': The caller does not have permission to execute the specified operation. - 'resource-exhausted': Some resource has been exhausted, perhaps a per-user quota, or perhaps the entire file system is out of space. - 'failed-precondition': Operation was rejected because the system is not in a state required for the operation's execution. - 'aborted': The operation was aborted, typically due to a concurrency issue like transaction aborts, etc. - 'out-of-range': Operation was attempted past the valid range. - 'unimplemented': Operation is not implemented or not supported/enabled. - 'internal': Internal errors. Means some invariants expected by underlying system has been broken. If you see one of these errors, something is very broken. - 'unavailable': The service is currently unavailable. This is most likely a transient condition and may be corrected by retrying with a backoff. - 'data-loss': Unrecoverable data loss or corruption. - 'unauthenticated': The request does not have valid authentication credentials for the operation. |
206206
| [FirestoreLocalCache](./firestore_.md#firestorelocalcache) | Union type from all supported SDK cache layer. |
207+
| [ListenSource](./firestore_.md#listensource) | Describe the source a query listens to.<!-- -->Set to <code>default</code> to listen to both cache and server changes. Set to <code>cache</code> to listen to changes in cache only. |
207208
| [MemoryGarbageCollector](./firestore_.md#memorygarbagecollector) | Union type from all support gabage collectors for memory local cache. |
208209
| [NestedUpdateFields](./firestore_.md#nestedupdatefields) | For each field (e.g. 'bar'), find all nested keys (e.g. {<!-- -->'bar.baz': T1, 'bar.qux': T2<!-- -->}<!-- -->). Intersect them together to make a single map containing all possible keys that are all marked as optional |
209210
| [OrderByDirection](./firestore_.md#orderbydirection) | The direction of a [orderBy()](./firestore_.md#orderby_006d61f) clause is specified as 'desc' or 'asc' (descending or ascending). |
@@ -2551,6 +2552,18 @@ Union type from all supported SDK cache layer.
25512552
export declare type FirestoreLocalCache = MemoryLocalCache | PersistentLocalCache;
25522553
```
25532554

2555+
## ListenSource
2556+
2557+
Describe the source a query listens to.
2558+
2559+
Set to `default` to listen to both cache and server changes. Set to `cache` to listen to changes in cache only.
2560+
2561+
<b>Signature:</b>
2562+
2563+
```typescript
2564+
export declare type ListenSource = 'default' | 'cache';
2565+
```
2566+
25542567
## MemoryGarbageCollector
25552568

25562569
Union type from all support gabage collectors for memory local cache.

docs-devsite/firestore_.snapshotlistenoptions.md

+11
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export declare interface SnapshotListenOptions
2323
| Property | Type | Description |
2424
| --- | --- | --- |
2525
| [includeMetadataChanges](./firestore_.snapshotlistenoptions.md#snapshotlistenoptionsincludemetadatachanges) | boolean | Include a change even if only the metadata of the query or of a document changed. Default is false. |
26+
| [source](./firestore_.snapshotlistenoptions.md#snapshotlistenoptionssource) | [ListenSource](./firestore_.md#listensource) | Set the source the query listens to. Default to "default", which listens to both cache and server. |
2627

2728
## SnapshotListenOptions.includeMetadataChanges
2829

@@ -33,3 +34,13 @@ Include a change even if only the metadata of the query or of a document changed
3334
```typescript
3435
readonly includeMetadataChanges?: boolean;
3536
```
37+
38+
## SnapshotListenOptions.source
39+
40+
Set the source the query listens to. Default to "default", which listens to both cache and server.
41+
42+
<b>Signature:</b>
43+
44+
```typescript
45+
readonly source?: ListenSource;
46+
```

packages/firestore/src/api.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,11 @@ export {
139139
WhereFilterOp
140140
} from './api/filter';
141141

142-
export { SnapshotListenOptions, Unsubscribe } from './api/reference_impl';
142+
export {
143+
ListenSource,
144+
SnapshotListenOptions,
145+
Unsubscribe
146+
} from './api/reference_impl';
143147

144148
export { TransactionOptions } from './api/transaction_options';
145149

packages/firestore/src/api/reference_impl.ts

+19-2
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
NextFn,
2525
PartialObserver
2626
} from '../api/observer';
27+
import { ListenerDataSource } from '../core/event_manager';
2728
import {
2829
firestoreClientAddSnapshotsInSyncListener,
2930
firestoreClientGetDocumentFromLocalCache,
@@ -78,8 +79,22 @@ export interface SnapshotListenOptions {
7879
* changed. Default is false.
7980
*/
8081
readonly includeMetadataChanges?: boolean;
82+
83+
/**
84+
* Set the source the query listens to. Default to "default", which
85+
* listens to both cache and server.
86+
*/
87+
readonly source?: ListenSource;
8188
}
8289

90+
/**
91+
* Describe the source a query listens to.
92+
*
93+
* Set to `default` to listen to both cache and server changes. Set to `cache`
94+
* to listen to changes in cache only.
95+
*/
96+
export type ListenSource = 'default' | 'cache';
97+
8398
/**
8499
* Reads the document referred to by this `DocumentReference`.
85100
*
@@ -668,7 +683,8 @@ export function onSnapshot<AppModelType, DbModelType extends DocumentData>(
668683
reference = getModularInstance(reference);
669684

670685
let options: SnapshotListenOptions = {
671-
includeMetadataChanges: false
686+
includeMetadataChanges: false,
687+
source: 'default'
672688
};
673689
let currArg = 0;
674690
if (typeof args[currArg] === 'object' && !isPartialObserver(args[currArg])) {
@@ -677,7 +693,8 @@ export function onSnapshot<AppModelType, DbModelType extends DocumentData>(
677693
}
678694

679695
const internalOptions = {
680-
includeMetadataChanges: options.includeMetadataChanges
696+
includeMetadataChanges: options.includeMetadataChanges,
697+
source: options.source as ListenerDataSource
681698
};
682699

683700
if (isPartialObserver(args[currArg])) {

packages/firestore/src/core/event_manager.ts

+144-24
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ import { ChangeType, DocumentViewChange, ViewSnapshot } from './view_snapshot';
3232
class QueryListenersInfo {
3333
viewSnap: ViewSnapshot | undefined = undefined;
3434
listeners: QueryListener[] = [];
35+
36+
// Helper methods that checks if the query has listeners that listening to remote store
37+
hasRemoteListeners(): boolean {
38+
return this.listeners.some(listener => listener.listensToRemoteStore());
39+
}
3540
}
3641

3742
/**
@@ -52,8 +57,13 @@ export interface Observer<T> {
5257
* allows users to tree-shake the Watch logic.
5358
*/
5459
export interface EventManager {
55-
onListen?: (query: Query) => Promise<ViewSnapshot>;
56-
onUnlisten?: (query: Query) => Promise<void>;
60+
onListen?: (
61+
query: Query,
62+
enableRemoteListen: boolean
63+
) => Promise<ViewSnapshot>;
64+
onUnlisten?: (query: Query, disableRemoteListen: boolean) => Promise<void>;
65+
onFirstRemoteStoreListen?: (query: Query) => Promise<void>;
66+
onLastRemoteStoreUnlisten?: (query: Query) => Promise<void>;
5767
}
5868

5969
export function newEventManager(): EventManager {
@@ -71,38 +81,104 @@ export class EventManagerImpl implements EventManager {
7181
snapshotsInSyncListeners: Set<Observer<void>> = new Set();
7282

7383
/** Callback invoked when a Query is first listen to. */
74-
onListen?: (query: Query) => Promise<ViewSnapshot>;
84+
onListen?: (
85+
query: Query,
86+
enableRemoteListen: boolean
87+
) => Promise<ViewSnapshot>;
7588
/** Callback invoked once all listeners to a Query are removed. */
76-
onUnlisten?: (query: Query) => Promise<void>;
89+
onUnlisten?: (query: Query, disableRemoteListen: boolean) => Promise<void>;
90+
91+
/**
92+
* Callback invoked when a Query starts listening to the remote store, while
93+
* already listening to the cache.
94+
*/
95+
onFirstRemoteStoreListen?: (query: Query) => Promise<void>;
96+
/**
97+
* Callback invoked when a Query stops listening to the remote store, while
98+
* still listening to the cache.
99+
*/
100+
onLastRemoteStoreUnlisten?: (query: Query) => Promise<void>;
101+
}
102+
103+
function validateEventManager(eventManagerImpl: EventManagerImpl): void {
104+
debugAssert(!!eventManagerImpl.onListen, 'onListen not set');
105+
debugAssert(
106+
!!eventManagerImpl.onFirstRemoteStoreListen,
107+
'onFirstRemoteStoreListen not set'
108+
);
109+
debugAssert(!!eventManagerImpl.onUnlisten, 'onUnlisten not set');
110+
debugAssert(
111+
!!eventManagerImpl.onLastRemoteStoreUnlisten,
112+
'onLastRemoteStoreUnlisten not set'
113+
);
114+
}
115+
116+
const enum ListenerSetupAction {
117+
InitializeLocalListenAndRequireWatchConnection,
118+
InitializeLocalListenOnly,
119+
RequireWatchConnectionOnly,
120+
NoActionRequired
121+
}
122+
123+
const enum ListenerRemovalAction {
124+
TerminateLocalListenAndRequireWatchDisconnection,
125+
TerminateLocalListenOnly,
126+
RequireWatchDisconnectionOnly,
127+
NoActionRequired
77128
}
78129

79130
export async function eventManagerListen(
80131
eventManager: EventManager,
81132
listener: QueryListener
82133
): Promise<void> {
83134
const eventManagerImpl = debugCast(eventManager, EventManagerImpl);
135+
validateEventManager(eventManagerImpl);
136+
137+
let listenerAction = ListenerSetupAction.NoActionRequired;
84138

85-
debugAssert(!!eventManagerImpl.onListen, 'onListen not set');
86139
const query = listener.query;
87-
let firstListen = false;
88140

89141
let queryInfo = eventManagerImpl.queries.get(query);
90142
if (!queryInfo) {
91-
firstListen = true;
92143
queryInfo = new QueryListenersInfo();
144+
listenerAction = listener.listensToRemoteStore()
145+
? ListenerSetupAction.InitializeLocalListenAndRequireWatchConnection
146+
: ListenerSetupAction.InitializeLocalListenOnly;
147+
} else if (
148+
!queryInfo.hasRemoteListeners() &&
149+
listener.listensToRemoteStore()
150+
) {
151+
// Query has been listening to local cache, and tries to add a new listener sourced from watch.
152+
listenerAction = ListenerSetupAction.RequireWatchConnectionOnly;
93153
}
94154

95-
if (firstListen) {
96-
try {
97-
queryInfo.viewSnap = await eventManagerImpl.onListen(query);
98-
} catch (e) {
99-
const firestoreError = wrapInUserErrorIfRecoverable(
100-
e as Error,
101-
`Initialization of query '${stringifyQuery(listener.query)}' failed`
102-
);
103-
listener.onError(firestoreError);
104-
return;
155+
try {
156+
switch (listenerAction) {
157+
case ListenerSetupAction.InitializeLocalListenAndRequireWatchConnection:
158+
queryInfo.viewSnap = await eventManagerImpl.onListen!(
159+
query,
160+
/** enableRemoteListen= */ true
161+
);
162+
break;
163+
case ListenerSetupAction.InitializeLocalListenOnly:
164+
queryInfo.viewSnap = await eventManagerImpl.onListen!(
165+
query,
166+
/** enableRemoteListen= */ false
167+
);
168+
break;
169+
case ListenerSetupAction.RequireWatchConnectionOnly:
170+
await eventManagerImpl.onFirstRemoteStoreListen!(query);
171+
break;
172+
default:
173+
break;
105174
}
175+
} catch (e) {
176+
const firestoreError = wrapInUserErrorIfRecoverable(
177+
e as Error,
178+
`Initialization of query '${stringifyQuery(listener.query)}' failed`
179+
);
180+
listener.onError(firestoreError);
181+
return;
106182
}
107183

108184
eventManagerImpl.queries.set(query, queryInfo);
@@ -130,23 +206,47 @@ export async function eventManagerUnlisten(
130206
listener: QueryListener
131207
): Promise<void> {
132208
const eventManagerImpl = debugCast(eventManager, EventManagerImpl);
209+
validateEventManager(eventManagerImpl);
133210

134-
debugAssert(!!eventManagerImpl.onUnlisten, 'onUnlisten not set');
135211
const query = listener.query;
136-
let lastListen = false;
212+
let listenerAction = ListenerRemovalAction.NoActionRequired;
137213

138214
const queryInfo = eventManagerImpl.queries.get(query);
139215
if (queryInfo) {
140216
const i = queryInfo.listeners.indexOf(listener);
141217
if (i >= 0) {
142218
queryInfo.listeners.splice(i, 1);
143-
lastListen = queryInfo.listeners.length === 0;
219+
220+
if (queryInfo.listeners.length === 0) {
221+
listenerAction = listener.listensToRemoteStore()
222+
? ListenerRemovalAction.TerminateLocalListenAndRequireWatchDisconnection
223+
: ListenerRemovalAction.TerminateLocalListenOnly;
224+
} else if (
225+
!queryInfo.hasRemoteListeners() &&
226+
listener.listensToRemoteStore()
227+
) {
228+
// The removed listener is the last one that sourced from watch.
229+
listenerAction = ListenerRemovalAction.RequireWatchDisconnectionOnly;
230+
}
144231
}
145232
}
146-
147-
if (lastListen) {
148-
eventManagerImpl.queries.delete(query);
149-
return eventManagerImpl.onUnlisten(query);
233+
switch (listenerAction) {
234+
case ListenerRemovalAction.TerminateLocalListenAndRequireWatchDisconnection:
235+
eventManagerImpl.queries.delete(query);
236+
return eventManagerImpl.onUnlisten!(
237+
query,
238+
/** disableRemoteListen= */ true
239+
);
240+
case ListenerRemovalAction.TerminateLocalListenOnly:
241+
eventManagerImpl.queries.delete(query);
242+
return eventManagerImpl.onUnlisten!(
243+
query,
244+
/** disableRemoteListen= */ false
245+
);
246+
case ListenerRemovalAction.RequireWatchDisconnectionOnly:
247+
return eventManagerImpl.onLastRemoteStoreUnlisten!(query);
248+
default:
249+
return;
150250
}
151251
}
152252

@@ -241,6 +341,14 @@ function raiseSnapshotsInSyncEvent(eventManagerImpl: EventManagerImpl): void {
241341
});
242342
}
243343

344+
export enum ListenerDataSource {
345+
/** Listen to both cache and server changes */
346+
Default = 'default',
347+
348+
/** Listen to changes in cache only */
349+
Cache = 'cache'
350+
}
351+
244352
export interface ListenOptions {
245353
/** Raise events even when only the metadata changes */
246354
readonly includeMetadataChanges?: boolean;
@@ -250,6 +358,9 @@ export interface ListenOptions {
250358
* offline.
251359
*/
252360
readonly waitForSyncWhenOnline?: boolean;
361+
362+
/** Set the source events raised from. */
363+
readonly source?: ListenerDataSource;
253364
}
254365

255366
/**
@@ -359,6 +470,11 @@ export class QueryListener {
359470
return true;
360471
}
361472

473+
// Always raise event if listening to cache
474+
if (!this.listensToRemoteStore()) {
475+
return true;
476+
}
477+
362478
// NOTE: We consider OnlineState.Unknown as online (it should become Offline
363479
// or Online if we wait long enough).
364480
const maybeOnline = onlineState !== OnlineState.Offline;
@@ -417,4 +533,8 @@ export class QueryListener {
417533
this.raisedInitialEvent = true;
418534
this.queryObserver.next(snap);
419535
}
536+
537+
listensToRemoteStore(): boolean {
538+
return this.options.source !== ListenerDataSource.Cache;
539+
}
420540
}

0 commit comments

Comments
 (0)