Skip to content

Commit b6453e8

Browse files
authored
firestore: re-write BackgroundQueue to NOT use the deprecated AsyncTask thread pool (#7376)
This change results in a significant performance improvement of queries with a large number of documents in their result set. In my testing, the performance improved by 91% from 3022 ms down to 265 ms on a real device! For some reason, there were _no_ performance gains on the Android emulator. This must have something to do with how different Android OS versions manage the threads in the (deprecated) AsyncTask thread pool. The test environment was as follows: - Pixel 7 Pro real device running Android 16. - Test called collectionRef.whereGreaterThan("foo", 50).get(Source.CACHE) 200 times. - Calculated the average amount of time it took for the query results to be received. - collectionRef is a Firestore collection containing 10,000 documents, of which 50% match the "whereGreaterThan" filter. - Local cache had no other documents in it. - Test app was compiled in "release" mode with "proguard-android-optimize.txt" r8 configuration.
1 parent 9f8a0f1 commit b6453e8

File tree

6 files changed

+110
-74
lines changed

6 files changed

+110
-74
lines changed

firebase-firestore/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
- [changed] Bumped internal dependencies.
44
- [changed] Improve the performance of queries in collections that contain many deleted documents.
55
[#7295](//github.com/firebase/firebase-android-sdk/issues/7295)
6+
- [changed] Improve query performance in large result sets by replacing the deprecated AsyncTask
7+
thread pool with a self-managed thread pool.
8+
[#7376](//github.com/firebase/firebase-android-sdk/issues/7376)
69

710
# 26.0.0
811

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

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@
2626
import com.google.firebase.firestore.model.mutation.Mutation;
2727
import com.google.firebase.firestore.model.mutation.Overlay;
2828
import com.google.firebase.firestore.util.BackgroundQueue;
29-
import com.google.firebase.firestore.util.Executors;
3029
import com.google.firestore.v1.Write;
3130
import com.google.protobuf.InvalidProtocolBufferException;
3231
import java.util.ArrayList;
@@ -35,7 +34,6 @@
3534
import java.util.List;
3635
import java.util.Map;
3736
import java.util.SortedSet;
38-
import java.util.concurrent.Executor;
3937

4038
public class SQLiteDocumentOverlayCache implements DocumentOverlayCache {
4139
private final SQLitePersistence db;
@@ -204,16 +202,21 @@ private void processOverlaysInBackground(
204202
byte[] rawMutation = row.getBlob(0);
205203
int largestBatchId = row.getInt(1);
206204

207-
// Since scheduling background tasks incurs overhead, we only dispatch to a
208-
// background thread if there are still some documents remaining.
209-
Executor executor = row.isLast() ? Executors.DIRECT_EXECUTOR : backgroundQueue;
210-
executor.execute(
205+
Runnable runnable =
211206
() -> {
212207
Overlay overlay = decodeOverlay(rawMutation, largestBatchId);
213208
synchronized (results) {
214209
results.put(overlay.getKey(), overlay);
215210
}
216-
});
211+
};
212+
213+
// If the cursor has exactly one row then just process that row synchronously to avoid the
214+
// unnecessary overhead of scheduling its processing to run asynchronously.
215+
if (row.isFirst() && row.isLast()) {
216+
runnable.run();
217+
} else {
218+
backgroundQueue.submit(runnable);
219+
}
217220
}
218221

219222
private Overlay decodeOverlay(byte[] rawMutation, int largestBatchId) {

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

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@
3333
import com.google.firebase.firestore.model.ResourcePath;
3434
import com.google.firebase.firestore.model.SnapshotVersion;
3535
import com.google.firebase.firestore.util.BackgroundQueue;
36-
import com.google.firebase.firestore.util.Executors;
3736
import com.google.firebase.firestore.util.Function;
3837
import com.google.protobuf.InvalidProtocolBufferException;
3938
import com.google.protobuf.MessageLite;
@@ -47,7 +46,6 @@
4746
import java.util.Objects;
4847
import java.util.Set;
4948
import java.util.concurrent.ConcurrentHashMap;
50-
import java.util.concurrent.Executor;
5149
import javax.annotation.Nonnull;
5250
import javax.annotation.Nullable;
5351

@@ -308,10 +306,7 @@ private void processRowInBackground(
308306
boolean documentTypeIsNull = row.isNull(3);
309307
String path = row.getString(4);
310308

311-
// Since scheduling background tasks incurs overhead, we only dispatch to a
312-
// background thread if there are still some documents remaining.
313-
Executor executor = row.isLast() ? Executors.DIRECT_EXECUTOR : backgroundQueue;
314-
executor.execute(
309+
Runnable runnable =
315310
() -> {
316311
MutableDocument document =
317312
decodeMaybeDocument(rawDocument, readTimeSeconds, readTimeNanos);
@@ -323,7 +318,15 @@ private void processRowInBackground(
323318
results.put(document.getKey(), document);
324319
}
325320
}
326-
});
321+
};
322+
323+
// If the cursor has exactly one row then just process that row synchronously to avoid the
324+
// unnecessary overhead of scheduling its processing to run asynchronously.
325+
if (row.isFirst() && row.isLast()) {
326+
runnable.run();
327+
} else {
328+
backgroundQueue.submit(runnable);
329+
}
327330
}
328331

329332
@Override

firebase-firestore/src/main/java/com/google/firebase/firestore/util/BackgroundQueue.java

Lines changed: 0 additions & 54 deletions
This file was deleted.
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.google.firebase.firestore.util
17+
18+
import java.util.concurrent.Executor
19+
import java.util.concurrent.Semaphore
20+
import kotlinx.coroutines.Dispatchers
21+
import kotlinx.coroutines.asExecutor
22+
23+
/**
24+
* Manages CPU-bound work on background threads to enable parallel processing.
25+
*
26+
* Instances of this class are _not_ thread-safe. All methods of an instance of this class must be
27+
* called from the same thread. The behavior of an instance is undefined if any of the methods are
28+
* called from multiple threads.
29+
*/
30+
internal class BackgroundQueue {
31+
32+
private var state: State = State.Submitting()
33+
34+
/**
35+
* Submit a task for asynchronous execution on the executor of the owning [BackgroundQueue].
36+
*
37+
* @throws IllegalStateException if [drain] has been called.
38+
*/
39+
fun submit(runnable: Runnable) {
40+
val submittingState = this.state
41+
check(submittingState is State.Submitting) { "submit() may not be called after drain()" }
42+
43+
submittingState.taskCount++
44+
executor.execute {
45+
try {
46+
runnable.run()
47+
} finally {
48+
submittingState.completedTasks.release()
49+
}
50+
}
51+
}
52+
53+
/**
54+
* Blocks until all tasks submitted via calls to [submit] have completed.
55+
*
56+
* @throws IllegalStateException if called more than once.
57+
*/
58+
fun drain() {
59+
val submittingState = this.state
60+
check(submittingState is State.Submitting) { "drain() may not be called more than once" }
61+
this.state = State.Draining
62+
63+
submittingState.completedTasks.acquire(submittingState.taskCount)
64+
}
65+
66+
private sealed interface State {
67+
class Submitting : State {
68+
val completedTasks = Semaphore(0)
69+
var taskCount: Int = 0
70+
}
71+
object Draining : State
72+
}
73+
74+
companion object {
75+
76+
/**
77+
* The maximum amount of parallelism shared by all instances of this class.
78+
*
79+
* This is equal to the number of processor cores available, or 2, whichever is larger.
80+
*/
81+
val maxParallelism = Runtime.getRuntime().availableProcessors().coerceAtLeast(2)
82+
83+
private val executor: Executor =
84+
Dispatchers.IO.limitedParallelism(maxParallelism, "firestore.BackgroundQueue").asExecutor()
85+
}
86+
}

firebase-firestore/src/test/java/com/google/firebase/firestore/core/QueryTest.java

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,12 @@
4040
import com.google.firebase.firestore.model.ResourcePath;
4141
import com.google.firebase.firestore.testutil.ComparatorTester;
4242
import com.google.firebase.firestore.util.BackgroundQueue;
43-
import com.google.firebase.firestore.util.Executors;
4443
import java.util.ArrayList;
4544
import java.util.Arrays;
4645
import java.util.HashMap;
4746
import java.util.Iterator;
4847
import java.util.List;
4948
import java.util.Map;
50-
import java.util.concurrent.Executor;
5149
import org.junit.Assert;
5250
import org.junit.Test;
5351
import org.junit.runner.RunWith;
@@ -991,10 +989,7 @@ public void testSynchronousMatchesOrderBy() {
991989

992990
while (iterator.hasNext()) {
993991
MutableDocument doc = iterator.next();
994-
// Only put the processing in the backgroundQueue if there are more documents
995-
// in the list. This behavior matches SQLiteRemoteDocumentCache.getAll(...)
996-
Executor executor = iterator.hasNext() ? backgroundQueue : Executors.DIRECT_EXECUTOR;
997-
executor.execute(
992+
backgroundQueue.submit(
998993
() -> {
999994
// We call query.matches() to indirectly test query.matchesOrderBy()
1000995
boolean result = query.matches(doc);

0 commit comments

Comments
 (0)