Skip to content

Commit 75ed530

Browse files
authored
Merge 3b60c56 into 7c20d7d
2 parents 7c20d7d + 3b60c56 commit 75ed530

File tree

3 files changed

+140
-92
lines changed

3 files changed

+140
-92
lines changed

packages/firestore/test/integration/api/query.test.ts

Lines changed: 103 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -2065,115 +2065,137 @@ apiDescribe('Queries', (persistence: boolean) => {
20652065
});
20662066

20672067
it('resuming a query should use bloom filter to avoid full requery', async () => {
2068-
// Create 100 documents in a new collection.
2068+
// Prepare the names and contents of the 100 documents to create.
20692069
const testDocs: { [key: string]: object } = {};
2070-
for (let i = 1; i <= 100; i++) {
2071-
testDocs['doc' + i] = { key: i };
2070+
for (let i = 0; i < 100; i++) {
2071+
testDocs['doc' + (1000 + i)] = { key: 42 };
20722072
}
20732073

20742074
// The function that runs a single iteration of the test.
2075-
// Below this definition, there is a "while" loop that calls this
2076-
// function potentially multiple times.
2075+
// Below this definition, there is a "while" loop that calls this function
2076+
// potentially multiple times.
20772077
const runTestIteration = async (
20782078
coll: CollectionReference,
20792079
db: Firestore
20802080
): Promise<'retry' | 'passed'> => {
2081-
// Run a query to populate the local cache with the 100 documents
2082-
// and a resume token.
2081+
// Run a query to populate the local cache with the 100 documents and a
2082+
// resume token.
20832083
const snapshot1 = await getDocs(coll);
20842084
expect(snapshot1.size, 'snapshot1.size').to.equal(100);
2085+
const createdDocuments = snapshot1.docs.map(snapshot => snapshot.ref);
20852086

2086-
// Delete 50 of the 100 documents. Do this in a transaction, rather
2087-
// than deleteDoc(), to avoid affecting the local cache.
2087+
// Delete 50 of the 100 documents. Do this in a transaction, rather than
2088+
// deleteDoc(), to avoid affecting the local cache.
2089+
const deletedDocumentIds = new Set<string>();
20882090
await runTransaction(db, async txn => {
2089-
for (let i = 1; i <= 50; i++) {
2090-
txn.delete(doc(coll, 'doc' + i));
2091+
for (let i = 0; i < createdDocuments.length; i += 2) {
2092+
const documentToDelete = createdDocuments[i];
2093+
txn.delete(documentToDelete);
2094+
deletedDocumentIds.add(documentToDelete.id);
20912095
}
20922096
});
20932097

2094-
// Wait for 10 seconds, during which Watch will stop tracking the
2095-
// query and will send an existence filter rather than "delete"
2096-
// events when the query is resumed.
2098+
// Wait for 10 seconds, during which Watch will stop tracking the query
2099+
// and will send an existence filter rather than "delete" events when the
2100+
// query is resumed.
20972101
await new Promise(resolve => setTimeout(resolve, 10000));
20982102

2099-
// Resume the query and expect to get a snapshot with the 50
2100-
// remaining documents. Use some internal testing hooks to "capture"
2101-
// the existence filter mismatches to later verify that Watch sent a
2102-
// bloom filter, and it was used to avert a full requery.
2103-
const existenceFilterMismatches = await captureExistenceFilterMismatches(
2104-
async () => {
2105-
const snapshot2 = await getDocs(coll);
2106-
// TODO(b/270731363): Remove the "if" condition below once the
2107-
// Firestore Emulator is fixed to send an existence filter. At the
2108-
// time of writing, the Firestore emulator fails to send an
2109-
// existence filter, resulting in the client including the deleted
2110-
// documents in the snapshot of the resumed query.
2111-
if (!(USE_EMULATOR && snapshot2.size === 100)) {
2112-
expect(snapshot2.size, 'snapshot2.size').to.equal(50);
2113-
}
2114-
}
2115-
);
2103+
// Resume the query and save the resulting snapshot for verification.
2104+
// Use some internal testing hooks to "capture" the existence filter
2105+
// mismatches to verify that Watch sent a bloom filter, and it was used to
2106+
// avert a full requery.
2107+
const [existenceFilterMismatches, snapshot2] =
2108+
await captureExistenceFilterMismatches(() => getDocs(coll));
2109+
2110+
// Verify that the snapshot from the resumed query contains the expected
2111+
// documents; that is, that it contains the 50 documents that were _not_
2112+
// deleted.
2113+
// TODO(b/270731363): Remove the "if" condition below once the
2114+
// Firestore Emulator is fixed to send an existence filter. At the time of
2115+
// writing, the Firestore emulator fails to send an existence filter,
2116+
// resulting in the client including the deleted documents in the snapshot
2117+
// of the resumed query.
2118+
if (!(USE_EMULATOR && snapshot2.size === 100)) {
2119+
const actualDocumentIds = snapshot2.docs
2120+
.map(documentSnapshot => documentSnapshot.ref.id)
2121+
.sort();
2122+
const expectedDocumentIds = createdDocuments
2123+
.filter(documentRef => !deletedDocumentIds.has(documentRef.id))
2124+
.map(documentRef => documentRef.id)
2125+
.sort();
2126+
expect(actualDocumentIds, 'snapshot2.docs').to.deep.equal(
2127+
expectedDocumentIds
2128+
);
2129+
}
21162130

2117-
// Skip the verification of the existence filter mismatch when
2118-
// persistence is disabled because without persistence there is no
2119-
// resume token specified in the subsequent call to getDocs(), and,
2120-
// therefore, Watch will _not_ send an existence filter.
2131+
// Skip the verification of the existence filter mismatch when persistence
2132+
// is disabled because without persistence there is no resume token
2133+
// specified in the subsequent call to getDocs(), and, therefore, Watch
2134+
// will _not_ send an existence filter.
2135+
// TODO(b/272754156) Re-write this test using a snapshot listener instead
2136+
// of calls to getDocs() and remove this check for disabled persistence.
21212137
if (!persistence) {
21222138
return 'passed';
21232139
}
21242140

2125-
// Skip the verification of the existence filter mismatch when
2126-
// testing against the Firestore emulator because the Firestore
2127-
// emulator does not include the `unchanged_names` bloom filter when
2128-
// it sends ExistenceFilter messages. Some day the emulator _may_
2129-
// implement this logic, at which time this short-circuit can be
2130-
// removed.
2141+
// Skip the verification of the existence filter mismatch when testing
2142+
// against the Firestore emulator because the Firestore emulator does not
2143+
// include the `unchanged_names` bloom filter when it sends
2144+
// ExistenceFilter messages. Some day the emulator _may_ implement this
2145+
// logic, at which time this short-circuit can be removed.
21312146
if (USE_EMULATOR) {
21322147
return 'passed';
21332148
}
21342149

2135-
// Verify that upon resuming the query that Watch sent an existence
2136-
// filter that included a bloom filter, and that the bloom filter
2137-
// was successfully used to avoid a full requery.
2138-
// TODO(b/271949433) Remove this check for "nightly" once the bloom
2139-
// filter logic is deployed to production, circa May 2023.
2140-
if (TARGET_BACKEND === 'nightly') {
2141-
expect(
2142-
existenceFilterMismatches,
2143-
'existenceFilterMismatches'
2144-
).to.have.length(1);
2145-
const { localCacheCount, existenceFilterCount, bloomFilter } =
2146-
existenceFilterMismatches[0];
2147-
2148-
expect(localCacheCount, 'localCacheCount').to.equal(100);
2149-
expect(existenceFilterCount, 'existenceFilterCount').to.equal(50);
2150-
if (!bloomFilter) {
2151-
expect.fail(
2152-
'The existence filter should have specified ' +
2153-
'a bloom filter in its `unchanged_names` field.'
2154-
);
2155-
throw new Error('should never get here');
2156-
}
2150+
// Verify that Watch sent an existence filter with the correct counts when
2151+
// the query was resumed.
2152+
expect(
2153+
existenceFilterMismatches,
2154+
'existenceFilterMismatches'
2155+
).to.have.length(1);
2156+
const { localCacheCount, existenceFilterCount, bloomFilter } =
2157+
existenceFilterMismatches[0];
2158+
expect(localCacheCount, 'localCacheCount').to.equal(100);
2159+
expect(existenceFilterCount, 'existenceFilterCount').to.equal(50);
2160+
2161+
// Skip the verification of the bloom filter when testing against
2162+
// production because the bloom filter is only implemented in nightly.
2163+
// TODO(b/271949433) Remove this "if" block once the bloom filter logic is
2164+
// deployed to production.
2165+
if (TARGET_BACKEND !== 'nightly') {
2166+
return 'passed';
2167+
}
21572168

2158-
expect(bloomFilter.hashCount, 'bloomFilter.hashCount').to.be.above(0);
2159-
expect(
2160-
bloomFilter.bitmapLength,
2161-
'bloomFilter.bitmapLength'
2162-
).to.be.above(0);
2163-
expect(bloomFilter.padding, 'bloomFilterPadding').to.be.above(0);
2164-
expect(bloomFilter.padding, 'bloomFilterPadding').to.be.below(8);
2165-
2166-
// Retry the entire test if a bloom filter false positive occurs.
2167-
// Although statistically rare, false positives are expected to
2168-
// happen occasionally. When a false positive _does_ happen, just
2169-
// retry the test with a different set of documents. If that retry
2170-
// _also_ experiences a false positive, then fail the test because
2171-
// that is so improbable that something must have gone wrong.
2172-
if (attemptNumber > 1 && !bloomFilter.applied) {
2173-
return 'retry';
2174-
}
2175-
expect(bloomFilter.applied, 'bloomFilter.applied').to.be.true;
2169+
// Verify that Watch sent a valid bloom filter.
2170+
if (!bloomFilter) {
2171+
expect.fail(
2172+
'The existence filter should have specified a bloom filter in its ' +
2173+
'`unchanged_names` field.'
2174+
);
2175+
throw new Error('should never get here');
2176+
}
2177+
2178+
expect(bloomFilter.hashCount, 'bloomFilter.hashCount').to.be.above(0);
2179+
expect(bloomFilter.bitmapLength, 'bloomFilter.bitmapLength').to.be.above(
2180+
0
2181+
);
2182+
expect(bloomFilter.padding, 'bloomFilterPadding').to.be.above(0);
2183+
expect(bloomFilter.padding, 'bloomFilterPadding').to.be.below(8);
2184+
2185+
// Verify that the bloom filter was successfully used to avert a full
2186+
// requery. If a false positive occurred then retry the entire test.
2187+
// Although statistically rare, false positives are expected to happen
2188+
// occasionally. When a false positive _does_ happen, just retry the test
2189+
// with a different set of documents. If that retry _also_ experiences a
2190+
// false positive, then fail the test because that is so improbable that
2191+
// something must have gone wrong.
2192+
if (attemptNumber === 1 && !bloomFilter.applied) {
2193+
return 'retry';
21762194
}
2195+
expect(
2196+
bloomFilter.applied,
2197+
`bloomFilter.applied with attemptNumber=${attemptNumber}`
2198+
).to.be.true;
21772199

21782200
return 'passed';
21792201
};

packages/firestore/test/integration/util/helpers.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ import {
3232
PrivateSettings,
3333
SnapshotListenOptions,
3434
newTestFirestore,
35-
newTestApp
35+
newTestApp,
36+
writeBatch,
37+
WriteBatch
3638
} from './firebase_export';
3739
import {
3840
ALT_PROJECT_ID,
@@ -315,11 +317,34 @@ export function withTestCollectionSettings<T>(
315317
const collectionId = 'test-collection-' + doc(collection(testDb, 'x')).id;
316318
const testCollection = collection(testDb, collectionId);
317319
const setupCollection = collection(setupDb, collectionId);
318-
const sets: Array<Promise<void>> = [];
319-
Object.keys(docs).forEach(key => {
320-
sets.push(setDoc(doc(setupCollection, key), docs[key]));
321-
});
322-
return Promise.all(sets).then(() => fn(testCollection, testDb));
320+
321+
const writeBatchCommits: Array<Promise<void>> = [];
322+
let writeBatch_: WriteBatch | null = null;
323+
let writeBatchSize = 0;
324+
325+
for (const key of Object.keys(docs)) {
326+
if (writeBatch_ === null) {
327+
writeBatch_ = writeBatch(setupDb);
328+
}
329+
330+
writeBatch_.set(doc(setupCollection, key), docs[key]);
331+
writeBatchSize++;
332+
333+
// Write batches are capped at 500 writes. Use 400 just to be safe.
334+
if (writeBatchSize === 400) {
335+
writeBatchCommits.push(writeBatch_.commit());
336+
writeBatch_ = null;
337+
writeBatchSize = 0;
338+
}
339+
}
340+
341+
if (writeBatch_ !== null) {
342+
writeBatchCommits.push(writeBatch_.commit());
343+
}
344+
345+
return Promise.all(writeBatchCommits).then(() =>
346+
fn(testCollection, testDb)
347+
);
323348
}
324349
);
325350
}

packages/firestore/test/integration/util/testing_hooks_util.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ import { _TestingHooks as TestingHooks } from './firebase_export';
2424
* callback all existence filter mismatches will be captured.
2525
* @return the captured existence filter mismatches.
2626
*/
27-
export async function captureExistenceFilterMismatches(
28-
callback: () => Promise<void>
29-
): Promise<ExistenceFilterMismatchInfo[]> {
27+
export async function captureExistenceFilterMismatches<T>(
28+
callback: () => Promise<T>
29+
): Promise<[ExistenceFilterMismatchInfo[], T]> {
3030
const results: ExistenceFilterMismatchInfo[] = [];
3131
const onExistenceFilterMismatchCallback = (
3232
info: ExistenceFilterMismatchInfo
@@ -39,13 +39,14 @@ export async function captureExistenceFilterMismatches(
3939
onExistenceFilterMismatchCallback
4040
);
4141

42+
let callbackResult: T;
4243
try {
43-
await callback();
44+
callbackResult = await callback();
4445
} finally {
4546
unregister();
4647
}
4748

48-
return results;
49+
return [results, callbackResult];
4950
}
5051

5152
/**

0 commit comments

Comments
 (0)