Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions firebase-firestore/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Unreleased

- [changed] Increased the gRPC maximum inbound message size limit to 17MB to support downloading Cloud Firestore documents of up to 16MB.
- [changed] Increased the default gRPC flow control window size from 64KB to 256KB to speed up large document reads, and added support for configuring this window size via `FirebaseFirestoreSettings.Builder.setGrpcFlowControlWindow()`.

# 26.4.0

- [feature] Added support for `minimum` and `maximum` FieldValue operations.
Expand Down
4 changes: 4 additions & 0 deletions firebase-firestore/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -253,9 +253,11 @@ package com.google.firebase.firestore {
public final class FirebaseFirestoreSettings {
method public com.google.firebase.firestore.LocalCacheSettings? getCacheSettings();
method @Deprecated public long getCacheSizeBytes();
method public int getGrpcFlowControlWindow();
method public String getHost();
method @Deprecated public boolean isPersistenceEnabled();
method public boolean isSslEnabled();
field public static final int DEFAULT_GRPC_FLOW_CONTROL_WINDOW = 262144; // 0x40000
field public static final long CACHE_SIZE_UNLIMITED = -1L; // 0xffffffffffffffffL
}

Expand All @@ -264,10 +266,12 @@ package com.google.firebase.firestore {
ctor public FirebaseFirestoreSettings.Builder(com.google.firebase.firestore.FirebaseFirestoreSettings);
method public com.google.firebase.firestore.FirebaseFirestoreSettings build();
method @Deprecated public long getCacheSizeBytes();
method public int getGrpcFlowControlWindow();
method public String getHost();
method @Deprecated public boolean isPersistenceEnabled();
method public boolean isSslEnabled();
method @Deprecated public com.google.firebase.firestore.FirebaseFirestoreSettings.Builder setCacheSizeBytes(long);
method public com.google.firebase.firestore.FirebaseFirestoreSettings.Builder setGrpcFlowControlWindow(int);
method public com.google.firebase.firestore.FirebaseFirestoreSettings.Builder setHost(String);
method public com.google.firebase.firestore.FirebaseFirestoreSettings.Builder setLocalCacheSettings(com.google.firebase.firestore.LocalCacheSettings);
method @Deprecated public com.google.firebase.firestore.FirebaseFirestoreSettings.Builder setPersistenceEnabled(boolean);
Expand Down
8 changes: 8 additions & 0 deletions firebase-firestore/firebase-firestore.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ android {
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles 'proguard.txt'

// By default, exclude large tests because they are slow.
// Run with `./gradlew :firebase-firestore:connectedCheck -PrunLargeTests` to run ONLY large tests.
if (project.hasProperty('runLargeTests')) {
testInstrumentationRunnerArguments annotation: 'androidx.test.filters.LargeTest'
} else {
testInstrumentationRunnerArguments notAnnotation: 'androidx.test.filters.LargeTest'
}

// Acceptable values are: 'emulator', 'qa', 'nightly', and 'prod'.
def targetBackend = findProperty("targetBackend") ?: "emulator"
buildConfigField("String", "TARGET_BACKEND", "\"$targetBackend\"")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,316 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package com.google.firebase.firestore;

import static com.google.firebase.firestore.testutil.IntegrationTestUtil.testFirestore;
import static com.google.firebase.firestore.testutil.IntegrationTestUtil.waitFor;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;

import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest;
import com.google.android.gms.tasks.Task;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import org.junit.After;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;
import org.junit.runner.RunWith;

@LargeTest
@RunWith(AndroidJUnit4.class)
public class LargeDocumentTest {

private static String seedCollection;
private static String unicodePayload;
private static String asciiPayload;

// Exteneded timeout because these tests can be slow.
private static final int TIMEOUT_MS = 120000;

private static String generateUnicodeString(int targetUtf8Bytes) {
StringBuilder sb = new StringBuilder();
String emoji = "🚀"; // 4 bytes in UTF-8
int bytes = 0;
while (bytes < targetUtf8Bytes) {
if (bytes % 2 == 0 && bytes + 4 <= targetUtf8Bytes) {
sb.append(emoji);
bytes += 4;
} else {
sb.append('a');
bytes += 1;
}
}
return sb.toString();
}

private static String generateAsciiString(int sizeInBytes) {
char[] chars = new char[sizeInBytes];
Arrays.fill(chars, 'a');
return new String(chars);
}

@BeforeClass
public static void setUpClass() {
FirebaseFirestore db = testFirestore();
seedCollection = "large_doc_tests_" + System.currentTimeMillis();

int targetBytes = (int) Math.floor(15.9 * 1024 * 1024);
unicodePayload = generateUnicodeString(targetBytes);
asciiPayload = generateAsciiString(targetBytes);

DocumentReference docRef = db.collection(seedCollection).document("doc_15_9MB_unicode");
DocumentReference docA = db.collection(seedCollection).document("doc_a");
DocumentReference docB = db.collection(seedCollection).document("doc_b");

Map<String, Object> dataUnicode = new HashMap<>();
dataUnicode.put("chunk", unicodePayload);
Map<String, Object> dataAscii = new HashMap<>();
dataAscii.put("chunk", asciiPayload);

waitFor(docRef.set(dataUnicode));
waitFor(docA.set(dataAscii));
waitFor(docB.set(dataAscii));
}

@AfterClass
public static void tearDownClass() {
if (seedCollection != null) {
FirebaseFirestore db = testFirestore();
try {
waitFor(db.collection(seedCollection).document("doc_15_9MB_unicode").delete());
waitFor(db.collection(seedCollection).document("doc_a").delete());
waitFor(db.collection(seedCollection).document("doc_b").delete());
} catch (Exception e) {
// Suppress cleanup exceptions
}
}
}

@After
public void tearDown() {
com.google.firebase.firestore.testutil.IntegrationTestUtil.tearDown();
}

@Test(timeout = TIMEOUT_MS)
public void testReadAndCacheLargeUnicodeDocument() {
FirebaseFirestore db = testFirestore();
DocumentReference docRef = db.collection(seedCollection).document("doc_15_9MB_unicode");

DocumentSnapshot serverSnapshot = waitFor(docRef.get(Source.SERVER));
assertTrue(serverSnapshot.exists());

waitFor(db.disableNetwork());

DocumentSnapshot cacheSnapshot = waitFor(docRef.get(Source.CACHE));
assertTrue(cacheSnapshot.exists());

assertEquals(serverSnapshot.getData(), cacheSnapshot.getData());

waitFor(db.enableNetwork());
}

@Test(timeout = TIMEOUT_MS)
public void testCacheIntegrityWithMultipleLargeDocuments() {
FirebaseFirestore db = testFirestore();

// Copy existing test environment settings but set a normal cache size
// to ensure we don't accidentally trigger async GC during the test.
FirebaseFirestoreSettings existingSettings = db.getFirestoreSettings();
FirebaseFirestoreSettings settings =
new FirebaseFirestoreSettings.Builder(existingSettings)
.setLocalCacheSettings(
PersistentCacheSettings.newBuilder().setSizeBytes(104857600).build()) // 100MB
.build();
db.setFirestoreSettings(settings);

CollectionReference colRef = db.collection(seedCollection);
DocumentReference docA = colRef.document("doc_a");
DocumentReference docB = colRef.document("doc_b");

waitFor(docA.get(Source.SERVER));
waitFor(docB.get(Source.SERVER));

waitFor(db.disableNetwork());

DocumentSnapshot cacheSnapshotA = waitFor(docA.get(Source.CACHE));
DocumentSnapshot cacheSnapshotB = waitFor(docB.get(Source.CACHE));

assertTrue("docA should exist in cache", cacheSnapshotA.exists());
assertTrue("docB should exist in cache", cacheSnapshotB.exists());

// Sanity check
assertTrue(cacheSnapshotA.getData().size() > 0);
assertTrue(cacheSnapshotB.getData().size() > 0);

waitFor(db.enableNetwork());
}

@Test(timeout = TIMEOUT_MS)
public void testWatchStreamInitializationAndDiff() throws Exception {
FirebaseFirestore db = testFirestore();
DocumentReference docRef = db.collection(seedCollection).document("doc_15_9MB_unicode");

// Verify that the initial snapshot of a large document is received successfully
// without triggering stream cancellation loops.
CountDownLatch updateLatch = new CountDownLatch(1);
ListenerRegistration registration =
docRef.addSnapshotListener(
(snapshot, error) -> {
if (snapshot != null
&& snapshot.exists()
&& snapshot.contains("differential_field")) {
updateLatch.countDown();
}
});

try {
Task<DocumentSnapshot> firstSnapshotTask = docRef.get(Source.SERVER);
DocumentSnapshot firstSnapshot = waitFor(firstSnapshotTask);
assertTrue(firstSnapshot.exists());

Map<String, Object> updateData = new HashMap<>();
updateData.put("differential_field", "updated_value");
waitFor(docRef.update(updateData));

assertTrue(
"Watch stream should deliver differential update",
updateLatch.await(60, TimeUnit.SECONDS));
} finally {
registration.remove();
}
}

@Test(timeout = TIMEOUT_MS)
public void testOversizedPayloadRejection() {
FirebaseFirestore db = testFirestore();
DocumentReference docRef = db.collection(seedCollection).document("temp_oversized_doc");

Map<String, Object> data = new HashMap<>();
// 16.1MB payload
int oversizedPayloadBytes = (16 * 1024 * 1024) + 102400;
data.put("largeField", generateAsciiString(oversizedPayloadBytes));

try {
waitFor(docRef.set(data));
fail("Setting a document exceeding the maximum size limit should fail.");
} catch (Exception e) {
assertTrue(e.getCause() instanceof FirebaseFirestoreException);
FirebaseFirestoreException firestoreException = (FirebaseFirestoreException) e.getCause();
assertEquals(
FirebaseFirestoreException.Code.INVALID_ARGUMENT, firestoreException.getCode());
}
}

@Test(timeout = TIMEOUT_MS)
public void testWriteValidLargeDocument() {
FirebaseFirestore db = testFirestore();
String tempDocId = "temp_valid_large_doc_" + System.currentTimeMillis();
DocumentReference docRef = db.collection(seedCollection).document(tempDocId);

try {
int targetBytes = (int) Math.floor(15.9 * 1024 * 1024);
String largePayload = generateAsciiString(targetBytes);
Map<String, Object> data = new HashMap<>();
data.put("chunk", largePayload);

waitFor(docRef.set(data));

DocumentSnapshot snapshot = waitFor(docRef.get(Source.SERVER));
assertTrue(snapshot.exists());
assertEquals(largePayload, snapshot.getString("chunk"));
} finally {
try {
waitFor(docRef.delete());
} catch (Exception e) {
// Suppress cleanup exceptions
}
}
}

@Test(timeout = TIMEOUT_MS)
public void testTransactionReadModifyWrite() {
FirebaseFirestore db = testFirestore();
DocumentReference docRef = db.collection(seedCollection).document("doc_15_9MB_unicode");

Task<Void> transactionTask =
db.runTransaction(
transaction -> {
DocumentSnapshot snapshot = transaction.get(docRef);
assertTrue(snapshot.exists());

transaction.update(docRef, "transaction_timestamp", System.currentTimeMillis());
return null;
});

waitFor(transactionTask);
}

@Test(timeout = TIMEOUT_MS)
public void testQueryLargeDocuments() {
FirebaseFirestore db = testFirestore();
CollectionReference colRef = db.collection(seedCollection);

Query query = colRef.whereIn(FieldPath.documentId(), Arrays.asList("doc_a", "doc_b"));

QuerySnapshot serverSnapshot = waitFor(query.get(Source.SERVER));
assertEquals(
"Query should return exactly 2 large documents from server", 2, serverSnapshot.size());

waitFor(db.disableNetwork());

QuerySnapshot cacheSnapshot = waitFor(query.get(Source.CACHE));
assertEquals(
"Query should return exactly 2 large documents from cache", 2, cacheSnapshot.size());

assertEquals(
"Cached query payload should exactly match server query payload",
serverSnapshot.getDocuments().get(0).getData(),
cacheSnapshot.getDocuments().get(0).getData());

waitFor(db.enableNetwork());
}

@Test(timeout = TIMEOUT_MS)
public void testQueryLargeDocumentsForcesLocalScan() {
FirebaseFirestore db = testFirestore();
CollectionReference colRef = db.collection(seedCollection);

waitFor(colRef.document("doc_a").get(Source.SERVER));
waitFor(colRef.document("doc_b").get(Source.SERVER));

waitFor(db.disableNetwork());

Query query = colRef.orderBy(FieldPath.documentId()).limit(2);

// Execute the query offline
QuerySnapshot cacheSnapshot = waitFor(query.get(Source.CACHE));

assertEquals(
"Query should find and return exactly 2 large documents from cache",
2,
cacheSnapshot.size());

assertTrue(
"Payload should not be empty", cacheSnapshot.getDocuments().get(0).getData().size() > 0);

waitFor(db.enableNetwork());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,12 @@ public void useEmulator(@NonNull String host, int port) {
private FirestoreClient newClient(AsyncQueue asyncQueue) {
synchronized (clientProvider) {
DatabaseInfo databaseInfo =
new DatabaseInfo(databaseId, persistenceKey, settings.getHost(), settings.isSslEnabled());
new DatabaseInfo(
databaseId,
persistenceKey,
settings.getHost(),
settings.isSslEnabled(),
settings.getGrpcFlowControlWindow());

return new FirestoreClient(
context,
Expand Down
Loading
Loading