Skip to content

Commit 6cb8e85

Browse files
Index-Free: Only exclude limit queries if last document changed
1 parent e11ca48 commit 6cb8e85

File tree

2 files changed

+99
-53
lines changed

2 files changed

+99
-53
lines changed

firebase-firestore/src/main/java/com/google/firebase/firestore/local/IndexFreeQueryEngine.java

Lines changed: 62 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
import com.google.firebase.firestore.model.DocumentKey;
2626
import com.google.firebase.firestore.model.MaybeDocument;
2727
import com.google.firebase.firestore.model.SnapshotVersion;
28+
import java.util.Comparator;
29+
import java.util.Iterator;
2830
import java.util.Map;
2931

3032
/**
@@ -76,72 +78,92 @@ public ImmutableSortedMap<DocumentKey, Document> getDocumentsMatchingQuery(
7678
return executeFullCollectionScan(query);
7779
}
7880

79-
ImmutableSortedMap<DocumentKey, Document> result =
80-
executeIndexFreeQuery(query, queryData, remoteKeys);
81+
ImmutableSortedMap<DocumentKey, Document> previousResults =
82+
getPreviousResults(query, remoteKeys);
8183

82-
return result != null ? result : executeFullCollectionScan(query);
84+
if (query.hasLimit()
85+
&& needsRefill(
86+
query, queryData.getLastLimboFreeSnapshotVersion(), previousResults, remoteKeys)) {
87+
return executeFullCollectionScan(query);
88+
}
89+
90+
// Retrieve all results for documents that were updated since the last limbo-document free
91+
// remote snapshot.
92+
ImmutableSortedMap<DocumentKey, Document> updatedResults =
93+
localDocumentsView.getDocumentsMatchingQuery(
94+
query, queryData.getLastLimboFreeSnapshotVersion());
95+
return previousResults.insertAll(updatedResults);
8396
}
8497

8598
/**
86-
* Attempts index-free query execution. Returns the set of query results on success, otherwise
87-
* returns null.
99+
* Returns the documents for the specified remote keys if they still match the query.
88100
*/
89-
private @Nullable ImmutableSortedMap<DocumentKey, Document> executeIndexFreeQuery(
90-
Query query, QueryData queryData, ImmutableSortedSet<DocumentKey> remoteKeys) {
101+
private ImmutableSortedMap<DocumentKey, Document> getPreviousResults(
102+
Query query, ImmutableSortedSet<DocumentKey> remoteKeys) {
91103
// Fetch the documents that matched the query at the last snapshot.
92104
ImmutableSortedMap<DocumentKey, MaybeDocument> previousResults =
93105
localDocumentsView.getDocuments(remoteKeys);
94106

95-
// Limit queries are not eligible for index-free query execution if any part of the result was
96-
// modified after we received the last query snapshot. This makes sure that we re-populate the
97-
// view with older documents that may sort before the modified document.
98-
if (query.hasLimit()
99-
&& containsUpdatesSinceSnapshotVersion(previousResults, queryData.getSnapshotVersion())) {
100-
return null;
101-
}
102-
103-
ImmutableSortedMap<DocumentKey, Document> results = DocumentCollections.emptyDocumentMap();
104-
105107
// Re-apply the query filter since previously matching documents do not necessarily still
106108
// match the query.
109+
ImmutableSortedMap<DocumentKey, Document> results = DocumentCollections.emptyDocumentMap();
107110
for (Map.Entry<DocumentKey, MaybeDocument> entry : previousResults) {
108111
MaybeDocument maybeDoc = entry.getValue();
109112
if (maybeDoc instanceof Document && query.matches((Document) maybeDoc)) {
110113
Document doc = (Document) maybeDoc;
111114
results = results.insert(entry.getKey(), doc);
112-
} else if (query.hasLimit()) {
113-
// Limit queries with documents that no longer match need to be re-filled from cache.
114-
return null;
115115
}
116116
}
117117

118-
// Retrieve all results for documents that were updated since the last limbo-document free
119-
// remote snapshot.
120-
ImmutableSortedMap<DocumentKey, Document> updatedResults =
121-
localDocumentsView.getDocumentsMatchingQuery(
122-
query, queryData.getLastLimboFreeSnapshotVersion());
123-
124-
results = results.insertAll(updatedResults);
125-
126118
return results;
127119
}
128120

129-
@Override
130-
public void handleDocumentChange(MaybeDocument oldDocument, MaybeDocument newDocument) {
131-
// No indexes to update.
132-
}
121+
/**
122+
* Determines if a limit query needs to be refilled from cache, making it ineligible for
123+
* index-free execution.
124+
*/
125+
private boolean needsRefill(
126+
Query query,
127+
SnapshotVersion limboFreeSnapshotVersion,
128+
ImmutableSortedMap<DocumentKey, Document> previousResults,
129+
ImmutableSortedSet<DocumentKey> remoteKeys) {
130+
hardAssert(query.hasLimit(), "Only limit queries should be refilled");
131+
132+
// The query doesn't need to be refilled if there were never any documents that matched the
133+
// query constraint.
134+
if (remoteKeys.isEmpty()) {
135+
return false;
136+
}
133137

134-
private boolean containsUpdatesSinceSnapshotVersion(
135-
ImmutableSortedMap<DocumentKey, MaybeDocument> previousResults,
136-
SnapshotVersion sinceSnapshotVersion) {
137-
for (Map.Entry<DocumentKey, MaybeDocument> doc : previousResults) {
138-
if (doc.getValue().hasPendingWrites()
139-
|| doc.getValue().getVersion().compareTo(sinceSnapshotVersion) > 0) {
140-
return true;
138+
// The query needs to be refilled if a previously matching document no longer matches.
139+
if (remoteKeys.size() != previousResults.size()) {
140+
return true;
141+
}
142+
143+
// Limit queries are not eligible for index-free query execution if an older document from cache
144+
// may sort before a document that was previously part of the limit. This can happen if the last
145+
// document of the limit could sort lower than it did when the query was last synchronized. To
146+
// verify this, we use the query's comparator to determine the last document that fit the
147+
// limit. If the last document was edited after the query was last synchronized, there is a
148+
// chance that another document from cache sorts higher, in which case we have to perform a full
149+
// cache scan.
150+
Comparator<Document> comparator = query.comparator();
151+
Iterator<Map.Entry<DocumentKey, Document>> it = previousResults.iterator();
152+
Document lastDocumentInLimit = it.next().getValue();
153+
while (it.hasNext()) {
154+
Document doc = it.next().getValue();
155+
if (comparator.compare(lastDocumentInLimit, doc) < 0) {
156+
lastDocumentInLimit = doc;
141157
}
142158
}
143159

144-
return false;
160+
return lastDocumentInLimit.hasPendingWrites()
161+
|| lastDocumentInLimit.getVersion().compareTo(limboFreeSnapshotVersion) > 0;
162+
}
163+
164+
@Override
165+
public void handleDocumentChange(MaybeDocument oldDocument, MaybeDocument newDocument) {
166+
// No indexes to update.
145167
}
146168

147169
private ImmutableSortedMap<DocumentKey, Document> executeFullCollectionScan(Query query) {

firebase-firestore/src/test/java/com/google/firebase/firestore/local/IndexFreeQueryEngineTest.java

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import static com.google.firebase.firestore.testutil.TestUtil.doc;
1818
import static com.google.firebase.firestore.testutil.TestUtil.docSet;
1919
import static com.google.firebase.firestore.testutil.TestUtil.filter;
20+
import static com.google.firebase.firestore.testutil.TestUtil.key;
2021
import static com.google.firebase.firestore.testutil.TestUtil.map;
2122
import static com.google.firebase.firestore.testutil.TestUtil.orderBy;
2223
import static com.google.firebase.firestore.testutil.TestUtil.query;
@@ -61,8 +62,6 @@ public class IndexFreeQueryEngineTest {
6162
doc("coll/a", 11, map("matches", true, "order", 1), Document.DocumentState.SYNCED);
6263
private static final Document MATCHING_DOC_B =
6364
doc("coll/b", 1, map("matches", true, "order", 2), Document.DocumentState.SYNCED);
64-
private static final Document NON_MATCHING_DOC_B =
65-
doc("coll/b", 1, map("matches", false, "order", 2), Document.DocumentState.SYNCED);
6665
private static final Document UPDATED_MATCHING_DOC_B =
6766
doc("coll/b", 11, map("matches", true, "order", 2), Document.DocumentState.SYNCED);
6867

@@ -102,13 +101,13 @@ public ImmutableSortedMap<DocumentKey, Document> getDocumentsMatchingQuery(
102101
}
103102

104103
/** Adds the provided documents to the query target mapping. */
105-
private void persistQueryMapping(Document... docs) {
104+
private void persistQueryMapping(DocumentKey... documentKeys) {
106105
persistence.runTransaction(
107106
"persistQueryMapping",
108107
() -> {
109108
ImmutableSortedSet<DocumentKey> remoteKeys = DocumentKey.emptyKeySet();
110-
for (Document doc : docs) {
111-
remoteKeys = remoteKeys.insert(doc.getKey());
109+
for (DocumentKey documentKey : documentKeys) {
110+
remoteKeys = remoteKeys.insert(documentKey);
112111
}
113112
queryCache.addMatchingKeys(remoteKeys, TEST_TARGET_ID);
114113
});
@@ -162,7 +161,7 @@ public void usesTargetMappingForInitialView() throws Exception {
162161
QueryData queryData = queryData(query, /* hasLimboFreeSnapshot= */ true);
163162

164163
addDocument(MATCHING_DOC_A, MATCHING_DOC_B);
165-
persistQueryMapping(MATCHING_DOC_A, MATCHING_DOC_B);
164+
persistQueryMapping(MATCHING_DOC_A.getKey(), MATCHING_DOC_B.getKey());
166165

167166
DocumentSet docs = expectIndexFreeQuery(() -> runQuery(query, queryData));
168167
assertEquals(docSet(query.comparator(), MATCHING_DOC_A, MATCHING_DOC_B), docs);
@@ -174,7 +173,7 @@ public void filtersNonMatchingInitialResults() throws Exception {
174173
QueryData queryData = queryData(query, /* hasLimboFreeSnapshot= */ true);
175174

176175
addDocument(MATCHING_DOC_A, MATCHING_DOC_B);
177-
persistQueryMapping(MATCHING_DOC_A, MATCHING_DOC_B);
176+
persistQueryMapping(MATCHING_DOC_A.getKey(), MATCHING_DOC_B.getKey());
178177

179178
// Add a mutated document that is not yet part of query's set of remote keys.
180179
addDocument(PENDING_NON_MATCHING_DOC_A);
@@ -189,7 +188,7 @@ public void includesChangesSinceInitialResults() throws Exception {
189188
QueryData originalQueryData = queryData(query, /* hasLimboFreeSnapshot= */ true);
190189

191190
addDocument(MATCHING_DOC_A, MATCHING_DOC_B);
192-
persistQueryMapping(MATCHING_DOC_A, MATCHING_DOC_B);
191+
persistQueryMapping(MATCHING_DOC_A.getKey(), MATCHING_DOC_B.getKey());
193192

194193
DocumentSet docs = expectIndexFreeQuery(() -> runQuery(query, originalQueryData));
195194
assertEquals(docSet(query.comparator(), MATCHING_DOC_A, MATCHING_DOC_B), docs);
@@ -225,7 +224,7 @@ public void doesNotUseInitialResultsForLimitQueryWithDocumentRemoval() throws Ex
225224
// While the backend would never add DocA to the set of remote keys, this allows us to easily
226225
// simulate what would happen when a document no longer matches due to an out-of-band update.
227226
addDocument(NON_MATCHING_DOC_A);
228-
persistQueryMapping(NON_MATCHING_DOC_A);
227+
persistQueryMapping(NON_MATCHING_DOC_A.getKey());
229228

230229
addDocument(MATCHING_DOC_B);
231230

@@ -235,7 +234,8 @@ public void doesNotUseInitialResultsForLimitQueryWithDocumentRemoval() throws Ex
235234
}
236235

237236
@Test
238-
public void doesNotUseInitialResultsForLimitQueryWithPendingWrite() throws Exception {
237+
public void doesNotUseInitialResultsForLimitQueryWhenLastDocumentHasPendingWrite()
238+
throws Exception {
239239
Query query =
240240
query("coll")
241241
.filter(filter("matches", "==", true))
@@ -245,7 +245,7 @@ public void doesNotUseInitialResultsForLimitQueryWithPendingWrite() throws Excep
245245
// Add a query mapping for a document that matches, but that sorts below another document due to
246246
// a pending write.
247247
addDocument(PENDING_MATCHING_DOC_A);
248-
persistQueryMapping(PENDING_MATCHING_DOC_A);
248+
persistQueryMapping(PENDING_MATCHING_DOC_A.getKey());
249249

250250
QueryData queryData = queryData(query, /* hasLimboFreeSnapshot= */ true);
251251

@@ -256,7 +256,7 @@ public void doesNotUseInitialResultsForLimitQueryWithPendingWrite() throws Excep
256256
}
257257

258258
@Test
259-
public void doesNotUseInitialResultsForLimitQueryWithDocumentThatHasBeenUpdatedOutOfBand()
259+
public void doesNotUseInitialResultsForLimitQueryWhenLastDocumentHasBeenUpdatedOutOfBand()
260260
throws Exception {
261261
Query query =
262262
query("coll")
@@ -267,7 +267,7 @@ public void doesNotUseInitialResultsForLimitQueryWithDocumentThatHasBeenUpdatedO
267267
// Add a query mapping for a document that matches, but that sorts below another document based
268268
// due to an update that the SDK received after the query's snapshot was persisted.
269269
addDocument(UDPATED_DOC_A);
270-
persistQueryMapping(UDPATED_DOC_A);
270+
persistQueryMapping(UDPATED_DOC_A.getKey());
271271

272272
QueryData queryData = queryData(query, /* hasLimboFreeSnapshot= */ true);
273273

@@ -277,6 +277,30 @@ public void doesNotUseInitialResultsForLimitQueryWithDocumentThatHasBeenUpdatedO
277277
assertEquals(docSet(query.comparator(), MATCHING_DOC_B), docs);
278278
}
279279

280+
@Test
281+
public void limitQueriesUseInitialResultsIfLastDocumentInLimitIsUnchanged() throws Exception {
282+
Query query = query("coll").orderBy(orderBy("order")).limit(2);
283+
284+
addDocument(doc("coll/a", 1, map("order", 1)));
285+
addDocument(doc("coll/b", 1, map("order", 3)));
286+
persistQueryMapping(key("coll/a"), key("coll/b"));
287+
QueryData queryData = queryData(query, /* hasLimboFreeSnapshot= */ true);
288+
289+
// Update "coll/a" but make sure it still sorts before "coll/b"
290+
addDocument(doc("coll/a", 1, map("order", 2), Document.DocumentState.LOCAL_MUTATIONS));
291+
292+
// Since the last document in the limit didn't change (and hence we know that all documents
293+
// written prior to query execution still sort after "coll/b"), we should use an Index-Free
294+
// query.
295+
DocumentSet docs = expectIndexFreeQuery(() -> runQuery(query, queryData));
296+
assertEquals(
297+
docSet(
298+
query.comparator(),
299+
doc("coll/a", 1, map("order", 2), Document.DocumentState.LOCAL_MUTATIONS),
300+
doc("coll/b", 1, map("order", 3))),
301+
docs);
302+
}
303+
280304
private QueryData queryData(Query query, boolean hasLimboFreeSnapshot) {
281305
return new QueryData(
282306
query,

0 commit comments

Comments
 (0)