Skip to content

Commit 539ab0d

Browse files
Add IndexFreeQueryEngine (#2169)
1 parent 988cc41 commit 539ab0d

File tree

10 files changed

+741
-110
lines changed

10 files changed

+741
-110
lines changed

packages/firestore/src/core/firestore_client.ts

Lines changed: 13 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,6 @@ import { LocalStore } from '../local/local_store';
2222
import { MemoryPersistence } from '../local/memory_persistence';
2323
import { Persistence } from '../local/persistence';
2424
import { SimpleQueryEngine } from '../local/simple_query_engine';
25-
import {
26-
DocumentKeySet,
27-
documentKeySet,
28-
DocumentMap
29-
} from '../model/collections';
3025
import { Document, MaybeDocument, NoDocument } from '../model/document';
3126
import { DocumentKey } from '../model/document_key';
3227
import { Mutation } from '../model/mutation';
@@ -45,7 +40,7 @@ import {
4540
QueryListener
4641
} from './event_manager';
4742
import { SyncEngine } from './sync_engine';
48-
import { View, ViewDocumentChanges } from './view';
43+
import { View } from './view';
4944

5045
import {
5146
LruGarbageCollector,
@@ -584,22 +579,18 @@ export class FirestoreClient {
584579

585580
getDocumentsFromLocalCache(query: Query): Promise<ViewSnapshot> {
586581
this.verifyNotTerminated();
587-
return this.asyncQueue
588-
.enqueue(() => {
589-
return this.localStore.executeQuery(query);
590-
})
591-
.then((docs: DocumentMap) => {
592-
const remoteKeys: DocumentKeySet = documentKeySet();
593-
594-
const view = new View(query, remoteKeys);
595-
const viewDocChanges: ViewDocumentChanges = view.computeDocChanges(
596-
docs
597-
);
598-
return view.applyChanges(
599-
viewDocChanges,
600-
/* updateLimboDocuments= */ false
601-
).snapshot!;
602-
});
582+
return this.asyncQueue.enqueue(async () => {
583+
const queryResult = await this.localStore.executeQuery(
584+
query,
585+
/* usePreviousResults= */ true
586+
);
587+
const view = new View(query, queryResult.remoteKeys);
588+
const viewDocChanges = view.computeDocChanges(queryResult.documents);
589+
return view.applyChanges(
590+
viewDocChanges,
591+
/* updateLimboDocuments= */ false
592+
).snapshot!;
593+
});
603594
}
604595

605596
write(mutations: Mutation[]): Promise<void> {

packages/firestore/src/core/sync_engine.ts

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -243,13 +243,12 @@ export class SyncEngine implements RemoteSyncer, SharedClientStateSyncer {
243243
current: boolean
244244
): Promise<ViewSnapshot> {
245245
const query = queryData.query;
246-
const docs = await this.localStore.executeQuery(query);
247-
const remoteKeys = await this.localStore.remoteDocumentKeys(
248-
queryData.targetId
246+
const queryResult = await this.localStore.executeQuery(
247+
query,
248+
/* usePreviousResults= */ true
249249
);
250-
251-
const view = new View(query, remoteKeys);
252-
const viewDocChanges = view.computeDocChanges(docs);
250+
const view = new View(query, queryResult.remoteKeys);
251+
const viewDocChanges = view.computeDocChanges(queryResult.documents);
253252
const synthesizedTargetChange = TargetChange.createSynthesizedTargetChangeForCurrentChange(
254253
queryData.targetId,
255254
current && this.onlineState !== OnlineState.Offline
@@ -282,13 +281,12 @@ export class SyncEngine implements RemoteSyncer, SharedClientStateSyncer {
282281
private async synchronizeViewAndComputeSnapshot(
283282
queryView: QueryView
284283
): Promise<ViewChange> {
285-
const docs = await this.localStore.executeQuery(queryView.query);
286-
const remoteKeys = await this.localStore.remoteDocumentKeys(
287-
queryView.targetId
284+
const queryResult = await this.localStore.executeQuery(
285+
queryView.query,
286+
/* usePreviousResults= */ true
288287
);
289288
const viewSnapshot = queryView.view.synchronizeWithPersistedState(
290-
docs,
291-
remoteKeys
289+
queryResult
292290
);
293291
if (this.isPrimary) {
294292
this.updateTrackedLimbos(queryView.targetId, viewSnapshot.limboChanges);
@@ -786,9 +784,14 @@ export class SyncEngine implements RemoteSyncer, SharedClientStateSyncer {
786784
// The query has a limit and some docs were removed, so we need
787785
// to re-run the query against the local store to make sure we
788786
// didn't lose any good docs that had been past the limit.
789-
return this.localStore.executeQuery(queryView.query).then(docs => {
790-
return queryView.view.computeDocChanges(docs, viewDocChanges);
791-
});
787+
return this.localStore
788+
.executeQuery(queryView.query, /* usePreviousResults= */ false)
789+
.then(({ documents }) => {
790+
return queryView.view.computeDocChanges(
791+
documents,
792+
viewDocChanges
793+
);
794+
});
792795
})
793796
.then((viewDocChanges: ViewDocumentChanges) => {
794797
const targetChange =

packages/firestore/src/core/view.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
* limitations under the License.
1616
*/
1717

18+
import { QueryResult } from '../local/local_store';
1819
import {
1920
documentKeySet,
2021
DocumentKeySet,
@@ -433,21 +434,18 @@ export class View {
433434
* of `syncedDocuments` since secondary clients update their query views
434435
* based purely on synthesized RemoteEvents.
435436
*
436-
* @param localDocs - The documents that match the query according to the
437-
* LocalStore.
438-
* @param remoteKeys - The keys of the documents that match the query
439-
* according to the backend.
437+
* @param queryResult.documents - The documents that match the query according
438+
* to the LocalStore.
439+
* @param queryResult.remoteKeys - The keys of the documents that match the
440+
* query according to the backend.
440441
*
441442
* @return The ViewChange that resulted from this synchronization.
442443
*/
443444
// PORTING NOTE: Multi-tab only.
444-
synchronizeWithPersistedState(
445-
localDocs: MaybeDocumentMap,
446-
remoteKeys: DocumentKeySet
447-
): ViewChange {
448-
this._syncedDocuments = remoteKeys;
445+
synchronizeWithPersistedState(queryResult: QueryResult): ViewChange {
446+
this._syncedDocuments = queryResult.remoteKeys;
449447
this.limboDocuments = documentKeySet();
450-
const docChanges = this.computeDocChanges(localDocs);
448+
const docChanges = this.computeDocChanges(queryResult.documents);
451449
return this.applyChanges(docChanges, /*updateLimboDocuments=*/ true);
452450
}
453451

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
/**
2+
* @license
3+
* Copyright 2019 Google Inc.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import { QueryEngine } from './query_engine';
19+
import { LocalDocumentsView } from './local_documents_view';
20+
import { PersistenceTransaction } from './persistence';
21+
import { PersistencePromise } from './persistence_promise';
22+
import { Query } from '../core/query';
23+
import { SnapshotVersion } from '../core/snapshot_version';
24+
import {
25+
DocumentKeySet,
26+
DocumentMap,
27+
MaybeDocumentMap
28+
} from '../model/collections';
29+
import { Document } from '../model/document';
30+
import { assert } from '../util/assert';
31+
import { debug, getLogLevel, LogLevel } from '../util/log';
32+
import { SortedSet } from '../util/sorted_set';
33+
34+
// TOOD(b/140938512): Drop SimpleQueryEngine and rename IndexFreeQueryEngine.
35+
36+
/**
37+
* A query engine that takes advantage of the target document mapping in the
38+
* QueryCache. The IndexFreeQueryEngine optimizes query execution by only
39+
* reading the documents that previously matched a query plus any documents that were
40+
* edited after the query was last listened to.
41+
*
42+
* There are some cases where Index-Free queries are not guaranteed to produce
43+
* the same results as full collection scans. In these cases, the
44+
* IndexFreeQueryEngine falls back to full query processing. These cases are:
45+
*
46+
* - Limit queries where a document that matched the query previously no longer
47+
* matches the query.
48+
*
49+
* - Limit queries where a document edit may cause the document to sort below
50+
* another document that is in the local cache.
51+
*
52+
* - Queries that have never been CURRENT or free of Limbo documents.
53+
*/
54+
export class IndexFreeQueryEngine implements QueryEngine {
55+
private localDocumentsView: LocalDocumentsView | undefined;
56+
57+
setLocalDocumentsView(localDocuments: LocalDocumentsView): void {
58+
this.localDocumentsView = localDocuments;
59+
}
60+
61+
getDocumentsMatchingQuery(
62+
transaction: PersistenceTransaction,
63+
query: Query,
64+
lastLimboFreeSnapshotVersion: SnapshotVersion,
65+
remoteKeys: DocumentKeySet
66+
): PersistencePromise<DocumentMap> {
67+
assert(
68+
this.localDocumentsView !== undefined,
69+
'setLocalDocumentsView() not called'
70+
);
71+
72+
// Queries that match all document don't benefit from using
73+
// IndexFreeQueries. It is more efficient to scan all documents in a
74+
// collection, rather than to perform individual lookups.
75+
if (query.matchesAllDocuments()) {
76+
return this.executeFullCollectionScan(transaction, query);
77+
}
78+
79+
// Queries that have never seen a snapshot without limbo free documents
80+
// should also be run as a full collection scan.
81+
if (lastLimboFreeSnapshotVersion.isEqual(SnapshotVersion.MIN)) {
82+
return this.executeFullCollectionScan(transaction, query);
83+
}
84+
85+
return this.localDocumentsView!.getDocuments(transaction, remoteKeys).next(
86+
documents => {
87+
const previousResults = this.applyQuery(query, documents);
88+
89+
if (
90+
query.hasLimit() &&
91+
this.needsRefill(
92+
previousResults,
93+
remoteKeys,
94+
lastLimboFreeSnapshotVersion
95+
)
96+
) {
97+
return this.executeFullCollectionScan(transaction, query);
98+
}
99+
100+
if (getLogLevel() <= LogLevel.DEBUG) {
101+
debug(
102+
'IndexFreeQueryEngine',
103+
'Re-using previous result from %s to execute query: %s',
104+
lastLimboFreeSnapshotVersion.toString(),
105+
query.toString()
106+
);
107+
}
108+
109+
// Retrieve all results for documents that were updated since the last
110+
// limbo-document free remote snapshot.
111+
return this.localDocumentsView!.getDocumentsMatchingQuery(
112+
transaction,
113+
query,
114+
lastLimboFreeSnapshotVersion
115+
).next(updatedResults => {
116+
// We merge `previousResults` into `updateResults`, since
117+
// `updateResults` is already a DocumentMap. If a document is
118+
// contained in both lists, then its contents are the same.
119+
previousResults.forEach(doc => {
120+
updatedResults = updatedResults.insert(doc.key, doc);
121+
});
122+
return updatedResults;
123+
});
124+
}
125+
);
126+
}
127+
128+
/** Applies the query filter and sorting to the provided documents. */
129+
private applyQuery(
130+
query: Query,
131+
documents: MaybeDocumentMap
132+
): SortedSet<Document> {
133+
// Sort the documents and re-apply the query filter since previously
134+
// matching documents do not necessarily still match the query.
135+
let queyrResults = new SortedSet<Document>((d1, d2) =>
136+
query.docComparator(d1, d2)
137+
);
138+
documents.forEach((_, maybeDoc) => {
139+
if (maybeDoc instanceof Document && query.matches(maybeDoc)) {
140+
queyrResults = queyrResults.add(maybeDoc);
141+
}
142+
});
143+
return queyrResults;
144+
}
145+
146+
/**
147+
* Determines if a limit query needs to be refilled from cache, making it
148+
* ineligible for index-free execution.
149+
*
150+
* @param sortedPreviousResults The documents that matched the query when it
151+
* was last synchronized, sorted by the query's comparator.
152+
* @param remoteKeys The document keys that matched the query at the last
153+
* snapshot.
154+
* @param limboFreeSnapshotVersion The version of the snapshot when the query
155+
* was last synchronized.
156+
*/
157+
private needsRefill(
158+
sortedPreviousResults: SortedSet<Document>,
159+
remoteKeys: DocumentKeySet,
160+
limboFreeSnapshotVersion: SnapshotVersion
161+
): boolean {
162+
// The query needs to be refilled if a previously matching document no
163+
// longer matches.
164+
if (remoteKeys.size !== sortedPreviousResults.size) {
165+
return true;
166+
}
167+
168+
// Limit queries are not eligible for index-free query execution if there is
169+
// a potential that an older document from cache now sorts before a document
170+
// that was previously part of the limit. This, however, can only happen if
171+
// the last document of the limit sorts lower than it did when the query was
172+
// last synchronized. If a document that is not the limit boundary sorts
173+
// differently, the boundary of the limit itself did not change and
174+
// documents from cache will continue to be "rejected" by this boundary.
175+
// Therefore, we can ignore any modifications that don't affect the last
176+
// document.
177+
const lastDocumentInLimit = sortedPreviousResults.last();
178+
if (!lastDocumentInLimit) {
179+
// We don't need to refill the query if there were already no documents.
180+
return false;
181+
}
182+
return (
183+
lastDocumentInLimit.hasPendingWrites ||
184+
lastDocumentInLimit.version.compareTo(limboFreeSnapshotVersion) > 0
185+
);
186+
}
187+
188+
private executeFullCollectionScan(
189+
transaction: PersistenceTransaction,
190+
query: Query
191+
): PersistencePromise<DocumentMap> {
192+
if (getLogLevel() <= LogLevel.DEBUG) {
193+
debug(
194+
'IndexFreeQueryEngine',
195+
'Using full collection scan to execute query: %s',
196+
query.toString()
197+
);
198+
}
199+
200+
return this.localDocumentsView!.getDocumentsMatchingQuery(
201+
transaction,
202+
query,
203+
SnapshotVersion.MIN
204+
);
205+
}
206+
}

0 commit comments

Comments
 (0)