diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/QueryTest.java b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/QueryTest.java index f607ddbbc85..b96986afcfd 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/QueryTest.java +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/QueryTest.java @@ -37,6 +37,7 @@ import com.google.android.gms.tasks.Task; import com.google.common.collect.Lists; import com.google.firebase.firestore.Query.Direction; +import com.google.firebase.firestore.remote.ExistenceFilterMismatchListener; import com.google.firebase.firestore.testutil.EventAccumulator; import com.google.firebase.firestore.testutil.IntegrationTestUtil; import java.util.ArrayList; @@ -1053,6 +1054,7 @@ public void resumingAQueryShouldUseExistenceFilterToDetectDeletes() throws Excep createdDocuments.add(documentSnapshot.getReference()); } } + assertWithMessage("createdDocuments").that(createdDocuments).hasSize(100); // Delete 50 of the 100 documents. Do this in a transaction, rather than // DocumentReference.delete(), to avoid affecting the local cache. @@ -1069,13 +1071,33 @@ public void resumingAQueryShouldUseExistenceFilterToDetectDeletes() throws Excep } return null; })); + assertWithMessage("deletedDocumentIds").that(deletedDocumentIds).hasSize(50); // Wait for 10 seconds, during which Watch will stop tracking the query and will send an // existence filter rather than "delete" events when the query is resumed. Thread.sleep(10000); - // Resume the query and save the resulting snapshot for verification. - QuerySnapshot snapshot2 = waitFor(collection.get()); + // Resume the query and save the resulting snapshot for verification. Use some internal testing + // hooks to "capture" the existence filter mismatches to verify them. + ExistenceFilterMismatchListener existenceFilterMismatchListener = + new ExistenceFilterMismatchListener(); + QuerySnapshot snapshot2; + ExistenceFilterMismatchListener.ExistenceFilterMismatchInfo existenceFilterMismatchInfo; + try { + existenceFilterMismatchListener.startListening(); + snapshot2 = waitFor(collection.get()); + // TODO(b/270731363): Remove the "if" condition below once the Firestore Emulator is fixed + // to send an existence filter. + if (isRunningAgainstEmulator()) { + existenceFilterMismatchInfo = null; + } else { + existenceFilterMismatchInfo = + existenceFilterMismatchListener.getOrWaitForExistenceFilterMismatch( + /*timeoutMillis=*/ 5000); + } + } finally { + existenceFilterMismatchListener.stopListening(); + } // Verify that the snapshot from the resumed query contains the expected documents; that is, // that it contains the 50 documents that were _not_ deleted. @@ -1098,6 +1120,26 @@ public void resumingAQueryShouldUseExistenceFilterToDetectDeletes() throws Excep .that(actualDocumentIds) .containsExactlyElementsIn(expectedDocumentIds); } + + // Skip the verification of the existence filter mismatch when testing against the Firestore + // emulator because the Firestore emulator fails to to send an existence filter at all. + // TODO(b/270731363): Enable the verification of the existence filter mismatch once the + // Firestore emulator is fixed to send an existence filter. + if (isRunningAgainstEmulator()) { + return; + } + + // Verify that Watch sent an existence filter with the correct counts when the query was + // resumed. + assertWithMessage("Watch should have sent an existence filter") + .that(existenceFilterMismatchInfo) + .isNotNull(); + assertWithMessage("localCacheCount") + .that(existenceFilterMismatchInfo.localCacheCount()) + .isEqualTo(100); + assertWithMessage("existenceFilterCount") + .that(existenceFilterMismatchInfo.existenceFilterCount()) + .isEqualTo(50); } @Test diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/remote/ExistenceFilterMismatchListener.java b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/remote/ExistenceFilterMismatchListener.java new file mode 100644 index 00000000000..6f185deaba7 --- /dev/null +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/remote/ExistenceFilterMismatchListener.java @@ -0,0 +1,150 @@ +// Copyright 2023 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.remote; + +import android.os.SystemClock; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.google.firebase.firestore.ListenerRegistration; +import java.util.ArrayList; + +/** + * Provides a mechanism for tests to listen for existence filter mismatches in the Watch "listen" + * stream. + */ +public final class ExistenceFilterMismatchListener { + + private TestingHooksExistenceFilterMismatchListenerImpl listener; + private ListenerRegistration listenerRegistration; + + /** + * Starts listening for existence filter mismatches. + * + * @throws IllegalStateException if this object is already started. + * @see #stopListening + */ + public synchronized void startListening() { + if (listener != null) { + throw new IllegalStateException("already registered"); + } + listener = new TestingHooksExistenceFilterMismatchListenerImpl(); + listenerRegistration = TestingHooks.getInstance().addExistenceFilterMismatchListener(listener); + } + + /** + * Stops listening for existence filter mismatches. + * + *
If listening has not been started then this method does nothing. + * + * @see #startListening + */ + public synchronized void stopListening() { + if (listenerRegistration != null) { + listenerRegistration.remove(); + } + listenerRegistration = null; + listener = null; + } + + /** + * Returns the oldest existence filter mismatch observed, waiting if none has yet been observed. + * + *
The oldest existence filter mismatch observed since the most recent successful invocation of
+ * {@link #startListening} will be returned. A subsequent invocation of this method will return
+ * the second-oldest existence filter mismatch observed, and so on. An invocation of {@link
+ * #stopListening} followed by another invocation of {@link #startListening} will discard any
+ * existence filter mismatches that occurred while previously started and will start observing
+ * afresh.
+ *
+ * @param timeoutMillis the maximum amount of time, in milliseconds, to wait for an existence
+ * filter mismatch to occur.
+ * @return information about the existence filter mismatch that occurred.
+ * @throws InterruptedException if waiting is interrupted.
+ * @throws IllegalStateException if this object has not been started by {@link #startListening}.
+ * @throws IllegalArgumentException if the given timeout is less than or equal to zero.
+ */
+ @Nullable
+ public ExistenceFilterMismatchInfo getOrWaitForExistenceFilterMismatch(long timeoutMillis)
+ throws InterruptedException {
+ if (timeoutMillis <= 0) {
+ throw new IllegalArgumentException("invalid timeout: " + timeoutMillis);
+ }
+
+ TestingHooksExistenceFilterMismatchListenerImpl registeredListener;
+ synchronized (this) {
+ registeredListener = listener;
+ }
+
+ if (registeredListener == null) {
+ throw new IllegalStateException(
+ "must be registered before waiting for an existence filter mismatch");
+ }
+
+ return registeredListener.getOrWaitForExistenceFilterMismatch(timeoutMillis);
+ }
+
+ private static final class TestingHooksExistenceFilterMismatchListenerImpl
+ implements TestingHooks.ExistenceFilterMismatchListener {
+
+ private final ArrayList Do not use this class except for testing purposes.
+ */
+@VisibleForTesting
+final class TestingHooks {
+
+ private static final TestingHooks instance = new TestingHooks();
+
+ // Use CopyOnWriteArrayList to store the listeners so that we don't need to worry about
+ // synchronizing adds, removes, and traversals.
+ private final CopyOnWriteArrayList The relative order in which callbacks are notified is unspecified; do not rely on any
+ * particular ordering. If a given callback is registered multiple times then it will be notified
+ * multiple times, once per registration.
+ *
+ * The thread on which the callback occurs is unspecified; listeners should perform their work
+ * as quickly as possible and return to avoid blocking any critical work. In particular, the
+ * listener callbacks should not block or perform long-running operations. Listener
+ * callbacks can occur concurrently with other callbacks on the same and other listeners.
+ *
+ * @param listener the listener to register.
+ * @return an object that unregisters the given listener via its {@link
+ * ListenerRegistration#remove} method; only the first unregistration request does anything;
+ * all subsequent requests do nothing.
+ */
+ ListenerRegistration addExistenceFilterMismatchListener(
+ @NonNull ExistenceFilterMismatchListener listener) {
+ checkNotNull(listener, "a null listener is not allowed");
+
+ AtomicReference