Skip to content

Commit eb702be

Browse files
Index-Free: Only exclude limit queries if last document changed (#737)
1 parent e11ca48 commit eb702be

File tree

3 files changed

+97
-65
lines changed

3 files changed

+97
-65
lines changed

firebase-database-collection/src/main/java/com/google/firebase/database/collection/ImmutableSortedMap.java

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,6 @@ public abstract class ImmutableSortedMap<K, V> implements Iterable<Map.Entry<K,
5656

5757
public abstract Comparator<K> getComparator();
5858

59-
public ImmutableSortedMap<K, V> insertAll(ImmutableSortedMap<K, V> source) {
60-
ImmutableSortedMap<K, V> result = this;
61-
for (Map.Entry<K, V> entry : source) {
62-
result = result.insert(entry.getKey(), entry.getValue());
63-
}
64-
return result;
65-
}
66-
6759
@Override
6860
@SuppressWarnings("unchecked")
6961
public boolean equals(Object o) {

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

Lines changed: 60 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,10 @@
2121
import com.google.firebase.database.collection.ImmutableSortedSet;
2222
import com.google.firebase.firestore.core.Query;
2323
import com.google.firebase.firestore.model.Document;
24-
import com.google.firebase.firestore.model.DocumentCollections;
2524
import com.google.firebase.firestore.model.DocumentKey;
2625
import com.google.firebase.firestore.model.MaybeDocument;
2726
import com.google.firebase.firestore.model.SnapshotVersion;
27+
import java.util.Collections;
2828
import java.util.Map;
2929

3030
/**
@@ -76,74 +76,90 @@ public ImmutableSortedMap<DocumentKey, Document> getDocumentsMatchingQuery(
7676
return executeFullCollectionScan(query);
7777
}
7878

79-
ImmutableSortedMap<DocumentKey, Document> result =
80-
executeIndexFreeQuery(query, queryData, remoteKeys);
79+
ImmutableSortedSet<Document> previousResults = getSortedPreviousResults(query, remoteKeys);
8180

82-
return result != null ? result : executeFullCollectionScan(query);
81+
if (query.hasLimit()
82+
&& needsRefill(previousResults, remoteKeys, queryData.getLastLimboFreeSnapshotVersion())) {
83+
return executeFullCollectionScan(query);
84+
}
85+
86+
// Retrieve all results for documents that were updated since the last limbo-document free
87+
// remote snapshot.
88+
ImmutableSortedMap<DocumentKey, Document> updatedResults =
89+
localDocumentsView.getDocumentsMatchingQuery(
90+
query, queryData.getLastLimboFreeSnapshotVersion());
91+
for (Document result : previousResults) {
92+
updatedResults = updatedResults.insert(result.getKey(), result);
93+
}
94+
95+
return 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, sorted by
100+
* the query's comparator.
88101
*/
89-
private @Nullable ImmutableSortedMap<DocumentKey, Document> executeIndexFreeQuery(
90-
Query query, QueryData queryData, ImmutableSortedSet<DocumentKey> remoteKeys) {
102+
private ImmutableSortedSet<Document> getSortedPreviousResults(
103+
Query query, ImmutableSortedSet<DocumentKey> remoteKeys) {
91104
// Fetch the documents that matched the query at the last snapshot.
92105
ImmutableSortedMap<DocumentKey, MaybeDocument> previousResults =
93106
localDocumentsView.getDocuments(remoteKeys);
94107

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-
105-
// Re-apply the query filter since previously matching documents do not necessarily still
106-
// match the query.
108+
// Sort the documents and re-apply the query filter since previously matching documents do not
109+
// necessarily still match the query.
110+
ImmutableSortedSet<Document> results =
111+
new ImmutableSortedSet<>(Collections.emptyList(), query.comparator());
107112
for (Map.Entry<DocumentKey, MaybeDocument> entry : previousResults) {
108113
MaybeDocument maybeDoc = entry.getValue();
109114
if (maybeDoc instanceof Document && query.matches((Document) maybeDoc)) {
110115
Document doc = (Document) maybeDoc;
111-
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;
116+
results = results.insert(doc);
115117
}
116118
}
119+
return results;
120+
}
117121

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());
122+
/**
123+
* Determines if a limit query needs to be refilled from cache, making it ineligible for
124+
* index-free execution.
125+
*
126+
* @param sortedPreviousResults The documents that matched the query when it was last
127+
* synchronized, sorted by the query's comparator.
128+
* @param remoteKeys The document keys that matched the query at the last snapshot.
129+
* @param limboFreeSnapshotVersion The version of the snapshot when the query was last
130+
* synchronized.
131+
*/
132+
private boolean needsRefill(
133+
ImmutableSortedSet<Document> sortedPreviousResults,
134+
ImmutableSortedSet<DocumentKey> remoteKeys,
135+
SnapshotVersion limboFreeSnapshotVersion) {
136+
// The query needs to be refilled if a previously matching document no longer matches.
137+
if (remoteKeys.size() != sortedPreviousResults.size()) {
138+
return true;
139+
}
123140

124-
results = results.insertAll(updatedResults);
141+
// We don't need to find a better match from cache if no documents matched the query.
142+
if (sortedPreviousResults.isEmpty()) {
143+
return false;
144+
}
125145

126-
return results;
146+
// Limit queries are not eligible for index-free query execution if there is a potential that an
147+
// older document from cache now sorts before a document that was previously part of the limit.
148+
// This, however, can only happen if the last document of the limit sorts lower than it did when
149+
// the query was last synchronized. If a document that is not the limit boundary sorts
150+
// differently, the boundary of the limit itself did not change and documents from cache will
151+
// continue to be "rejected" by this boundary. Therefore, we can ignore any modifications that
152+
// don't affect the last document.
153+
Document lastDocumentInLimit = sortedPreviousResults.getMaxEntry();
154+
return lastDocumentInLimit.hasPendingWrites()
155+
|| lastDocumentInLimit.getVersion().compareTo(limboFreeSnapshotVersion) > 0;
127156
}
128157

129158
@Override
130159
public void handleDocumentChange(MaybeDocument oldDocument, MaybeDocument newDocument) {
131160
// No indexes to update.
132161
}
133162

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;
141-
}
142-
}
143-
144-
return false;
145-
}
146-
147163
private ImmutableSortedMap<DocumentKey, Document> executeFullCollectionScan(Query query) {
148164
return localDocumentsView.getDocumentsMatchingQuery(query, SnapshotVersion.NONE);
149165
}

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)