Skip to content

Commit 56c238c

Browse files
Re-run limit queries without limit if they need to be re-filled (#3657)
* Re-run limit queries without limit if they need to be re-filled This changes the query engine to also drop the limit on limit queries that need to be refilled. It also cleans up some of the Target.getLimit() logic that leads to repetitive code. * Fix test * Debugging tests via CI is not fun * Add test * Maybe test fix? Wish I could run it locally * Next try * Fix logic * Format
1 parent d308ce3 commit 56c238c

File tree

7 files changed

+63
-61
lines changed

7 files changed

+63
-61
lines changed

firebase-firestore/src/main/java/com/google/firebase/firestore/Query.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
package com.google.firebase.firestore;
1616

17+
import static com.google.firebase.firestore.core.Query.LimitType.LIMIT_TO_LAST;
1718
import static com.google.firebase.firestore.util.Assert.fail;
1819
import static com.google.firebase.firestore.util.Assert.hardAssert;
1920
import static com.google.firebase.firestore.util.Preconditions.checkNotNull;
@@ -1216,7 +1217,7 @@ private ListenerRegistration addSnapshotListenerInternal(
12161217
}
12171218

12181219
private void validateHasExplicitOrderByForLimitToLast() {
1219-
if (query.hasLimitToLast() && query.getExplicitOrderBy().isEmpty()) {
1220+
if (query.getLimitType().equals(LIMIT_TO_LAST) && query.getExplicitOrderBy().isEmpty()) {
12201221
throw new IllegalStateException(
12211222
"limitToLast() queries require specifying at least one orderBy() clause");
12221223
}

firebase-firestore/src/main/java/com/google/firebase/firestore/core/Query.java

Lines changed: 4 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -147,34 +147,16 @@ public List<Filter> getFilters() {
147147
return filters;
148148
}
149149

150-
/**
151-
* The maximum number of results to return. If there is no limit on the query, then this will
152-
* cause an assertion failure.
153-
*/
154-
public long getLimitToFirst() {
155-
hardAssert(hasLimitToFirst(), "Called getLimitToFirst when no limit was set");
156-
return limit;
157-
}
158-
159-
public boolean hasLimitToFirst() {
160-
return limitType == LimitType.LIMIT_TO_FIRST && limit != Target.NO_LIMIT;
161-
}
162-
163-
/**
164-
* The maximum number of last-matching results to return. If there is no limit on the query, then
165-
* this will cause an assertion failure.
166-
*/
167-
public long getLimitToLast() {
168-
hardAssert(hasLimitToLast(), "Called getLimitToLast when no limit was set");
150+
/** The maximum number of results to return or {@link Target#NO_LIMIT} if there is no limit. */
151+
public long getLimit() {
169152
return limit;
170153
}
171154

172-
public boolean hasLimitToLast() {
173-
return limitType == LimitType.LIMIT_TO_LAST && limit != Target.NO_LIMIT;
155+
public boolean hasLimit() {
156+
return limit != Target.NO_LIMIT;
174157
}
175158

176159
public LimitType getLimitType() {
177-
hardAssert(hasLimitToLast() || hasLimitToFirst(), "Called getLimitType when no limit was set");
178160
return limitType;
179161
}
180162

firebase-firestore/src/main/java/com/google/firebase/firestore/core/View.java

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414

1515
package com.google.firebase.firestore.core;
1616

17+
import static com.google.firebase.firestore.core.Query.LimitType.LIMIT_TO_FIRST;
18+
import static com.google.firebase.firestore.core.Query.LimitType.LIMIT_TO_LAST;
1719
import static com.google.firebase.firestore.util.Assert.hardAssert;
1820
import static com.google.firebase.firestore.util.Util.compareIntegers;
1921

@@ -147,11 +149,11 @@ public DocumentChanges computeDocChanges(
147149
// Note that this should never get used in a refill (when previousChanges is set), because there
148150
// will only be adds -- no deletes or updates.
149151
Document lastDocInLimit =
150-
(query.hasLimitToFirst() && oldDocumentSet.size() == query.getLimitToFirst())
152+
(query.getLimitType().equals(LIMIT_TO_FIRST) && oldDocumentSet.size() == query.getLimit())
151153
? oldDocumentSet.getLastDocument()
152154
: null;
153155
Document firstDocInLimit =
154-
(query.hasLimitToLast() && oldDocumentSet.size() == query.getLimitToLast())
156+
(query.getLimitType().equals(LIMIT_TO_LAST) && oldDocumentSet.size() == query.getLimit())
155157
? oldDocumentSet.getFirstDocument()
156158
: null;
157159

@@ -222,11 +224,10 @@ public DocumentChanges computeDocChanges(
222224
}
223225

224226
// Drop documents out to meet limitToFirst/limitToLast requirement.
225-
if (query.hasLimitToFirst() || query.hasLimitToLast()) {
226-
long limit = query.hasLimitToFirst() ? query.getLimitToFirst() : query.getLimitToLast();
227-
for (long i = newDocumentSet.size() - limit; i > 0; --i) {
227+
if (query.hasLimit()) {
228+
for (long i = newDocumentSet.size() - query.getLimit(); i > 0; --i) {
228229
Document oldDoc =
229-
query.hasLimitToFirst()
230+
query.getLimitType().equals(LIMIT_TO_FIRST)
230231
? newDocumentSet.getLastDocument()
231232
: newDocumentSet.getFirstDocument();
232233
newDocumentSet = newDocumentSet.remove(oldDoc.getKey());

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

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
package com.google.firebase.firestore.local;
1616

1717
import static com.google.firebase.firestore.util.Assert.hardAssert;
18-
import static com.google.firebase.firestore.util.Util.values;
1918

2019
import com.google.firebase.database.collection.ImmutableSortedMap;
2120
import com.google.firebase.database.collection.ImmutableSortedSet;
@@ -117,15 +116,14 @@ public ImmutableSortedMap<DocumentKey, Document> getDocumentsMatchingQuery(
117116
return null;
118117
}
119118

120-
if (indexType.equals(IndexType.PARTIAL)) {
119+
if (query.hasLimit() && indexType.equals(IndexType.PARTIAL)) {
121120
// We cannot apply a limit for targets that are served using a partial index.
122121
// If a partial index will be used to serve the target, the query may return a superset of
123122
// documents that match the target (e.g. if the index doesn't include all the target's
124123
// filters), or may return the correct set of documents in the wrong order (e.g. if the index
125124
// doesn't include a segment for one of the orderBys). Therefore a limit should not be applied
126125
// in such cases.
127-
query = query.limitToFirst(Target.NO_LIMIT);
128-
target = query.toTarget();
126+
return performQueryUsingIndex(query.limitToFirst(Target.NO_LIMIT));
129127
}
130128

131129
List<DocumentKey> keys = indexManager.getDocumentsMatchingTarget(target);
@@ -136,12 +134,15 @@ public ImmutableSortedMap<DocumentKey, Document> getDocumentsMatchingQuery(
136134
IndexOffset offset = indexManager.getMinOffset(target);
137135

138136
ImmutableSortedSet<Document> previousResults = applyQuery(query, indexedDocuments);
139-
if ((query.hasLimitToFirst() || query.hasLimitToLast())
140-
&& needsRefill(query.getLimitType(), keys.size(), previousResults, offset.getReadTime())) {
141-
return null;
137+
if (needsRefill(query, keys.size(), previousResults, offset.getReadTime())) {
138+
// A limit query whose boundaries change due to local edits can be re-run against the cache
139+
// by excluding the limit. This ensures that all documents that match the query's filters are
140+
// included in the result set. The SDK can then apply the limit once all local edits are
141+
// incorporated.
142+
return performQueryUsingIndex(query.limitToFirst(Target.NO_LIMIT));
142143
}
143144

144-
return appendRemainingResults(values(indexedDocuments), query, offset);
145+
return appendRemainingResults(previousResults, query, offset);
145146
}
146147

147148
/**
@@ -167,12 +168,7 @@ && needsRefill(query.getLimitType(), keys.size(), previousResults, offset.getRea
167168
localDocumentsView.getDocuments(remoteKeys);
168169
ImmutableSortedSet<Document> previousResults = applyQuery(query, documents);
169170

170-
if ((query.hasLimitToFirst() || query.hasLimitToLast())
171-
&& needsRefill(
172-
query.getLimitType(),
173-
remoteKeys.size(),
174-
previousResults,
175-
lastLimboFreeSnapshotVersion)) {
171+
if (needsRefill(query, remoteKeys.size(), previousResults, lastLimboFreeSnapshotVersion)) {
176172
return null;
177173
}
178174

@@ -211,7 +207,7 @@ private ImmutableSortedSet<Document> applyQuery(
211207
* Determines if a limit query needs to be refilled from cache, making it ineligible for
212208
* index-free execution.
213209
*
214-
* @param limitType The type of limit query for refill calculation.
210+
* @param query The query.
215211
* @param expectedDocumentCount The number of documents keys that matched the query at the last
216212
* snapshot.
217213
* @param sortedPreviousResults The documents that match the query based on the previous result,
@@ -221,12 +217,17 @@ private ImmutableSortedSet<Document> applyQuery(
221217
* synchronized.
222218
*/
223219
private boolean needsRefill(
224-
Query.LimitType limitType,
220+
Query query,
225221
int expectedDocumentCount,
226222
ImmutableSortedSet<Document> sortedPreviousResults,
227223
SnapshotVersion limboFreeSnapshotVersion) {
228-
// The query needs to be refilled if a previously matching document no longer matches.
224+
if (!query.hasLimit()) {
225+
// Queries without limits do not need to be refilled.
226+
return false;
227+
}
228+
229229
if (expectedDocumentCount != sortedPreviousResults.size()) {
230+
// The query needs to be refilled if a previously matching document no longer matches.
230231
return true;
231232
}
232233

@@ -237,7 +238,7 @@ private boolean needsRefill(
237238
// did not change and documents from cache will continue to be "rejected" by this boundary.
238239
// Therefore, we can ignore any modifications that don't affect the last document.
239240
Document documentAtLimitEdge =
240-
limitType == Query.LimitType.LIMIT_TO_FIRST
241+
query.getLimitType() == Query.LimitType.LIMIT_TO_FIRST
241242
? sortedPreviousResults.getMaxEntry()
242243
: sortedPreviousResults.getMinEntry();
243244
if (documentAtLimitEdge == null) {

firebase-firestore/src/test/java/com/google/firebase/firestore/bundle/BundleSerializerTest.java

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -712,30 +712,26 @@ private void assertDecodesNamedQuery(String json, Query query) throws JSONExcept
712712
+ json
713713
+ ",\n"
714714
+ " limitType: '"
715-
+ (query.hasLimitToLast() ? "LAST" : "FIRST")
715+
+ (query.getLimitType().equals(Query.LimitType.LIMIT_TO_LAST) ? "LAST" : "FIRST")
716716
+ "'\n"
717717
+ " },\n"
718718
+ " readTime: '2020-01-01T00:00:01.000000001Z'\n"
719719
+ "}";
720720
NamedQuery actualNamedQuery = serializer.decodeNamedQuery(new JSONObject(queryJson));
721721

722-
long limit =
723-
query.hasLimitToFirst()
724-
? query.getLimitToFirst()
725-
: (query.hasLimitToLast() ? query.getLimitToLast() : Target.NO_LIMIT);
726722
Target target =
727723
new Target(
728724
query.getPath(),
729725
query.getCollectionGroup(),
730726
query.getFilters(),
731727
query.getExplicitOrderBy(),
732-
limit,
728+
query.getLimit(),
733729
query.getStartAt(),
734730
query.getEndAt());
735731
BundledQuery bundledQuery =
736732
new BundledQuery(
737733
target,
738-
query.hasLimitToLast()
734+
query.getLimitType().equals(Query.LimitType.LIMIT_TO_LAST)
739735
? Query.LimitType.LIMIT_TO_LAST
740736
: Query.LimitType.LIMIT_TO_FIRST);
741737
NamedQuery expectedNamedQuery =

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ public void testUsesPartiallyIndexedOverlaysWhenAvailable() {
213213
}
214214

215215
@Test
216-
public void testDoesNotUseIndexForLimitQueryWhenIndexIsOutdated() {
216+
public void testDoesNotUseLimitWhenIndexIsOutdated() {
217217
FieldIndex index =
218218
fieldIndex("coll", 0, FieldIndex.INITIAL_STATE, "count", FieldIndex.Segment.Kind.ASCENDING);
219219
configureFieldIndexes(singletonList(index));
@@ -234,10 +234,10 @@ public void testDoesNotUseIndexForLimitQueryWhenIndexIsOutdated() {
234234
writeMutation(deleteMutation("coll/b"));
235235

236236
executeQuery(query);
237-
// The query engine first reads the documents by key and then discards the results, which means
238-
// that we read both by key and by collection.
239-
assertRemoteDocumentsRead(/* byKey= */ 2, /* byCollection= */ 3);
240-
assertOverlaysRead(/* byKey= */ 2, /* byCollection= */ 1);
237+
238+
// The query engine first reads the documents by key and then re-runs the query without limit.
239+
assertRemoteDocumentsRead(/* byKey= */ 5, /* byCollection= */ 0);
240+
assertOverlaysRead(/* byKey= */ 5, /* byCollection= */ 1);
241241
assertQueryReturned("coll/a", "coll/c");
242242
}
243243

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

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
import static com.google.firebase.firestore.testutil.TestUtil.fieldIndex;
2424
import static com.google.firebase.firestore.testutil.TestUtil.filter;
2525
import static com.google.firebase.firestore.testutil.TestUtil.map;
26+
import static com.google.firebase.firestore.testutil.TestUtil.orderBy;
27+
import static com.google.firebase.firestore.testutil.TestUtil.patchMutation;
2628
import static com.google.firebase.firestore.testutil.TestUtil.query;
2729
import static com.google.firebase.firestore.testutil.TestUtil.setMutation;
2830
import static org.junit.Assert.assertEquals;
@@ -46,7 +48,7 @@ Persistence getPersistence() {
4648
}
4749

4850
@Test
49-
public void combinesIndexedWithNonIndexedResults() throws Exception {
51+
public void testCombinesIndexedWithNonIndexedResults() throws Exception {
5052
MutableDocument doc1 = doc("coll/a", 1, map("foo", true));
5153
MutableDocument doc2 = doc("coll/b", 2, map("foo", true));
5254
MutableDocument doc3 = doc("coll/c", 3, map("foo", true));
@@ -70,7 +72,7 @@ public void combinesIndexedWithNonIndexedResults() throws Exception {
7072
}
7173

7274
@Test
73-
public void usesPartialIndexForLimitQueries() throws Exception {
75+
public void testUsesPartialIndexForLimitQueries() throws Exception {
7476
MutableDocument doc1 = doc("coll/1", 1, map("a", 1, "b", 0));
7577
MutableDocument doc2 = doc("coll/2", 1, map("a", 1, "b", 1));
7678
MutableDocument doc3 = doc("coll/3", 1, map("a", 1, "b", 2));
@@ -87,4 +89,23 @@ public void usesPartialIndexForLimitQueries() throws Exception {
8789
DocumentSet result = expectOptimizedCollectionScan(() -> runQuery(query, SnapshotVersion.NONE));
8890
assertEquals(docSet(query.comparator(), doc2), result);
8991
}
92+
93+
@Test
94+
public void testRefillsIndexedLimitQueries() throws Exception {
95+
MutableDocument doc1 = doc("coll/1", 1, map("a", 1));
96+
MutableDocument doc2 = doc("coll/2", 1, map("a", 2));
97+
MutableDocument doc3 = doc("coll/3", 1, map("a", 3));
98+
MutableDocument doc4 = doc("coll/4", 1, map("a", 4));
99+
addDocument(doc1, doc2, doc3, doc4);
100+
101+
indexManager.addFieldIndex(fieldIndex("coll", "a", Kind.ASCENDING));
102+
indexManager.updateIndexEntries(docMap(doc1, doc2, doc3, doc4));
103+
indexManager.updateCollectionGroup("coll", IndexOffset.fromDocument(doc4));
104+
105+
addMutation(patchMutation("coll/3", map("a", 5)));
106+
107+
Query query = query("coll").orderBy(orderBy("a")).limitToFirst(3);
108+
DocumentSet result = expectOptimizedCollectionScan(() -> runQuery(query, SnapshotVersion.NONE));
109+
assertEquals(docSet(query.comparator(), doc1, doc2, doc4), result);
110+
}
90111
}

0 commit comments

Comments
 (0)