Skip to content

Commit 74fc4cf

Browse files
authored
Add test cases for existence filter with updated/added documents (#7457)
1 parent 2d0a9f5 commit 74fc4cf

File tree

3 files changed

+351
-5
lines changed

3 files changed

+351
-5
lines changed

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

+169
Original file line numberDiff line numberDiff line change
@@ -2203,6 +2203,175 @@ apiDescribe('Queries', persistence => {
22032203
}
22042204
).timeout('90s');
22052205

2206+
// TODO(b/291365820): Stop skipping this test when running against the
2207+
// Firestore emulator once the emulator is improved to include a bloom filter
2208+
// in the existence filter messages that it sends.
2209+
// eslint-disable-next-line no-restricted-properties
2210+
(USE_EMULATOR ? it.skip : it)(
2211+
'bloom filter should avert a full re-query when documents were added, ' +
2212+
'deleted, removed, updated, and unchanged since the resume token',
2213+
async () => {
2214+
// Prepare the names and contents of the 20 documents to create.
2215+
const testDocs: { [key: string]: object } = {};
2216+
for (let i = 0; i < 20; i++) {
2217+
testDocs['doc' + (1000 + i)] = {
2218+
key: 42,
2219+
removed: false
2220+
};
2221+
}
2222+
2223+
// Ensure that the local cache is configured to use LRU garbage
2224+
// collection (rather than eager garbage collection) so that the resume
2225+
// token and document data does not get prematurely evicted.
2226+
const lruPersistence = persistence.toLruGc();
2227+
2228+
return withRetry(async attemptNumber => {
2229+
return withTestCollection(lruPersistence, testDocs, async coll => {
2230+
// Run a query to populate the local cache with the 20 documents
2231+
// and a resume token.
2232+
const snapshot1 = await getDocs(
2233+
query(coll, where('removed', '==', false))
2234+
);
2235+
expect(snapshot1.size, 'snapshot1.size').to.equal(20);
2236+
const createdDocuments = snapshot1.docs.map(snapshot => snapshot.ref);
2237+
2238+
// Out of the 20 existing documents, leave 5 docs untouched, delete 5 docs,
2239+
// remove 5 docs, update 5 docs, and add 15 new docs.
2240+
const deletedDocumentIds = new Set<string>();
2241+
const removedDocumentIds = new Set<string>();
2242+
const updatedDocumentIds = new Set<string>();
2243+
const addedDocumentIds: string[] = [];
2244+
2245+
// Use a different Firestore instance to avoid affecting the local cache.
2246+
await withTestDb(PERSISTENCE_MODE_UNSPECIFIED, async db2 => {
2247+
const batch = writeBatch(db2);
2248+
2249+
for (let i = 0; i < createdDocuments.length; i += 4) {
2250+
const documentToDelete = doc(db2, createdDocuments[i].path);
2251+
batch.delete(documentToDelete);
2252+
deletedDocumentIds.add(documentToDelete.id);
2253+
}
2254+
expect(deletedDocumentIds.size).to.equal(5);
2255+
2256+
// Update 5 documents to no longer match the query.
2257+
for (let i = 1; i < createdDocuments.length; i += 4) {
2258+
const documentToModify = doc(db2, createdDocuments[i].path);
2259+
batch.update(documentToModify, {
2260+
removed: true
2261+
});
2262+
removedDocumentIds.add(documentToModify.id);
2263+
}
2264+
expect(removedDocumentIds.size).to.equal(5);
2265+
2266+
// Update 5 documents, but ensure they still match the query.
2267+
for (let i = 2; i < createdDocuments.length; i += 4) {
2268+
const documentToModify = doc(db2, createdDocuments[i].path);
2269+
batch.update(documentToModify, {
2270+
key: 43
2271+
});
2272+
updatedDocumentIds.add(documentToModify.id);
2273+
}
2274+
expect(updatedDocumentIds.size).to.equal(5);
2275+
2276+
for (let i = 0; i < 15; i += 1) {
2277+
const documentToAdd = doc(
2278+
db2,
2279+
coll.path + '/newDoc' + (1000 + i)
2280+
);
2281+
batch.set(documentToAdd, {
2282+
key: 42,
2283+
removed: false
2284+
});
2285+
addedDocumentIds.push(documentToAdd.id);
2286+
}
2287+
2288+
// Ensure the sets above are disjoint.
2289+
const mergedSet = new Set<string>();
2290+
[
2291+
deletedDocumentIds,
2292+
removedDocumentIds,
2293+
updatedDocumentIds,
2294+
addedDocumentIds
2295+
].forEach(set => {
2296+
set.forEach(documentId => mergedSet.add(documentId));
2297+
});
2298+
expect(mergedSet.size).to.equal(30);
2299+
2300+
await batch.commit();
2301+
});
2302+
2303+
// Wait for 10 seconds, during which Watch will stop tracking the
2304+
// query and will send an existence filter rather than "delete"
2305+
// events when the query is resumed.
2306+
await new Promise(resolve => setTimeout(resolve, 10000));
2307+
2308+
// Resume the query and save the resulting snapshot for
2309+
// verification. Use some internal testing hooks to "capture" the
2310+
// existence filter mismatches to verify that Watch sent a bloom
2311+
// filter, and it was used to avert a full requery.
2312+
const [existenceFilterMismatches, snapshot2] =
2313+
await captureExistenceFilterMismatches(() =>
2314+
getDocs(query(coll, where('removed', '==', false)))
2315+
);
2316+
2317+
// Verify that the snapshot from the resumed query contains the
2318+
// expected documents; that is, 10 existing documents that still
2319+
// match the query, and 15 documents that are newly added.
2320+
const actualDocumentIds = snapshot2.docs
2321+
.map(documentSnapshot => documentSnapshot.ref.id)
2322+
.sort();
2323+
const expectedDocumentIds = createdDocuments
2324+
.map(documentRef => documentRef.id)
2325+
.filter(documentId => !deletedDocumentIds.has(documentId))
2326+
.filter(documentId => !removedDocumentIds.has(documentId))
2327+
.concat(addedDocumentIds)
2328+
.sort();
2329+
2330+
expect(actualDocumentIds, 'snapshot2.docs').to.deep.equal(
2331+
expectedDocumentIds
2332+
);
2333+
expect(actualDocumentIds.length).to.equal(25);
2334+
2335+
// Verify that Watch sent an existence filter with the correct
2336+
// counts when the query was resumed.
2337+
expect(
2338+
existenceFilterMismatches,
2339+
'existenceFilterMismatches'
2340+
).to.have.length(1);
2341+
const { localCacheCount, existenceFilterCount, bloomFilter } =
2342+
existenceFilterMismatches[0];
2343+
expect(localCacheCount, 'localCacheCount').to.equal(35);
2344+
expect(existenceFilterCount, 'existenceFilterCount').to.equal(25);
2345+
2346+
// Verify that Watch sent a valid bloom filter.
2347+
if (!bloomFilter) {
2348+
expect.fail(
2349+
'The existence filter should have specified a bloom filter ' +
2350+
'in its `unchanged_names` field.'
2351+
);
2352+
throw new Error('should never get here');
2353+
}
2354+
2355+
// Verify that the bloom filter was successfully used to avert a
2356+
// full requery. If a false positive occurred then retry the entire
2357+
// test. Although statistically rare, false positives are expected
2358+
// to happen occasionally. When a false positive _does_ happen, just
2359+
// retry the test with a different set of documents. If that retry
2360+
// also_ experiences a false positive, then fail the test because
2361+
// that is so improbable that something must have gone wrong.
2362+
if (attemptNumber === 1 && !bloomFilter.applied) {
2363+
throw new RetryError();
2364+
}
2365+
2366+
expect(
2367+
bloomFilter.applied,
2368+
`bloomFilter.applied with attemptNumber=${attemptNumber}`
2369+
).to.be.true;
2370+
});
2371+
});
2372+
}
2373+
).timeout('90s');
2374+
22062375
// TODO(b/291365820): Stop skipping this test when running against the
22072376
// Firestore emulator once the emulator is improved to include a bloom filter
22082377
// in the existence filter messages that it sends.

packages/firestore/test/unit/specs/existence_filter_spec.test.ts

+179-2
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,8 @@ describeSpec('Existence Filters:', [], () => {
273273
);
274274

275275
/**
276-
* Test existence filter with bloom filter.
276+
* Test existence filter with bloom filter. Existence filters below is sent mid-stream for
277+
* testing simplicity.
277278
*/
278279
specTest(
279280
'Full re-query is skipped when bloom filter can identify documents deleted',
@@ -626,9 +627,185 @@ describeSpec('Existence Filters:', [], () => {
626627
// Doc0 to doc49 are deleted in the next sync.
627628
.watchFilters([query1], docKeys.slice(0, 50), bloomFilterProto)
628629
.watchSnapshots(2000)
629-
// BloomFilter correctly identifies docs that deleted, skip full query.
630+
// Bloom Filter correctly identifies docs that deleted, skips full query.
630631
.expectEvents(query1, { fromCache: true })
631632
.expectLimboDocs(...docKeys.slice(50))
632633
);
633634
});
635+
636+
specTest(
637+
'Resume a query with bloom filter when there is no document changes',
638+
[],
639+
() => {
640+
const query1 = query('collection');
641+
const docA = doc('collection/a', 1000, { v: 1 });
642+
const bloomFilterProto = generateBloomFilterProto({
643+
contains: [docA],
644+
notContains: []
645+
});
646+
return (
647+
spec()
648+
.userListens(query1)
649+
.watchAcksFull(query1, 1000, docA)
650+
.expectEvents(query1, { added: [docA] })
651+
.disableNetwork()
652+
.expectEvents(query1, { fromCache: true })
653+
.enableNetwork()
654+
.restoreListen(query1, 'resume-token-1000')
655+
.watchAcks(query1)
656+
// Nothing happened while this client was disconnected.
657+
// Bloom Filter includes docA as there are no changes since the resume token.
658+
.watchFilters([query1], [docA.key], bloomFilterProto)
659+
// Expected count equals to documents in cache. Existence Filter matches.
660+
.watchCurrents(query1, 'resume-token-2000')
661+
.watchSnapshots(2000)
662+
.expectEvents(query1, { fromCache: false })
663+
);
664+
}
665+
);
666+
667+
specTest(
668+
'Resume a query with bloom filter when new documents are added',
669+
[],
670+
() => {
671+
const query1 = query('collection');
672+
const docA = doc('collection/a', 1000, { v: 1 });
673+
const docB = doc('collection/b', 1000, { v: 2 });
674+
const bloomFilterProto = generateBloomFilterProto({
675+
contains: [docA, docB],
676+
notContains: []
677+
});
678+
return (
679+
spec()
680+
.userListens(query1)
681+
.watchAcksFull(query1, 1000, docA)
682+
.expectEvents(query1, { added: [docA] })
683+
.disableNetwork()
684+
.expectEvents(query1, { fromCache: true })
685+
.enableNetwork()
686+
.restoreListen(query1, 'resume-token-1000')
687+
.watchAcks(query1)
688+
// While this client was disconnected, another client added docB.
689+
.watchSends({ affects: [query1] }, docB)
690+
// Bloom Filter includes all the documents that match the query, both
691+
// those that haven't changed since the resume token and those newly added.
692+
.watchFilters([query1], [docA.key, docB.key], bloomFilterProto)
693+
// Expected count equals to documents in cache. Existence Filter matches.
694+
.watchCurrents(query1, 'resume-token-2000')
695+
.watchSnapshots(2000)
696+
.expectEvents(query1, { added: [docB], fromCache: false })
697+
);
698+
}
699+
);
700+
701+
specTest(
702+
'Resume a query with bloom filter when existing docs are updated',
703+
[],
704+
() => {
705+
const query1 = query('collection', filter('v', '>=', 1));
706+
const docA = doc('collection/a', 1000, { v: 1 });
707+
const docB = doc('collection/b', 1000, { v: 1 });
708+
const updatedDocB = doc('collection/b', 1000, { v: 2 });
709+
710+
const bloomFilterProto = generateBloomFilterProto({
711+
contains: [docA, updatedDocB],
712+
notContains: []
713+
});
714+
return (
715+
spec()
716+
.userListens(query1)
717+
.watchAcksFull(query1, 1000, docA, docB)
718+
.expectEvents(query1, { added: [docA, docB] })
719+
.disableNetwork()
720+
.expectEvents(query1, { fromCache: true })
721+
.enableNetwork()
722+
.restoreListen(query1, 'resume-token-1000')
723+
.watchAcks(query1)
724+
// While this client was disconnected, another client updated fields in docB.
725+
.watchSends({ affects: [query1] }, updatedDocB)
726+
// Bloom Filter includes all the documents that match the query, both
727+
// those that have changed since the resume token and those that have not.
728+
.watchFilters([query1], [docA.key, updatedDocB.key], bloomFilterProto)
729+
// Expected count equals to documents in cache. Existence Filter matches.
730+
.watchCurrents(query1, 'resume-token-2000')
731+
.watchSnapshots(2000)
732+
.expectEvents(query1, { fromCache: false })
733+
);
734+
}
735+
);
736+
737+
specTest(
738+
'Resume a query with bloom filter when documents are updated to no longer match the query',
739+
[],
740+
() => {
741+
const query1 = query('collection', filter('v', '==', 1));
742+
const docA = doc('collection/a', 1000, { v: 1 });
743+
const docB = doc('collection/b', 1000, { v: 1 });
744+
const updatedDocB = doc('collection/b', 2000, { v: 2 });
745+
746+
const bloomFilterProto = generateBloomFilterProto({
747+
contains: [docA],
748+
notContains: [docB]
749+
});
750+
return (
751+
spec()
752+
.userListens(query1)
753+
.watchAcksFull(query1, 1000, docA, docB)
754+
.expectEvents(query1, { added: [docA, docB] })
755+
.disableNetwork()
756+
.expectEvents(query1, { fromCache: true })
757+
.enableNetwork()
758+
.restoreListen(query1, 'resume-token-1000')
759+
.watchAcks(query1)
760+
// While this client was disconnected, another client modified docB to no longer match the
761+
// query. Bloom Filter includes only docA that matches the query since the resume token.
762+
.watchFilters([query1], [docA.key], bloomFilterProto)
763+
.watchCurrents(query1, 'resume-token-2000')
764+
.watchSnapshots(2000)
765+
// Bloom Filter identifies that updatedDocB no longer matches the query, skips full query
766+
// and puts updatedDocB into limbo directly.
767+
.expectLimboDocs(updatedDocB.key) // updatedDocB is now in limbo.
768+
);
769+
}
770+
);
771+
772+
specTest(
773+
'Resume a query with bloom filter when documents are added, removed and deleted',
774+
[],
775+
() => {
776+
const query1 = query('collection', filter('v', '==', 1));
777+
const docA = doc('collection/a', 1000, { v: 1 });
778+
const docB = doc('collection/b', 1000, { v: 1 });
779+
const updatedDocB = doc('collection/b', 2000, { v: 2 });
780+
const docC = doc('collection/c', 1000, { v: 1 });
781+
const docD = doc('collection/d', 1000, { v: 1 });
782+
const bloomFilterProto = generateBloomFilterProto({
783+
contains: [docA, docD],
784+
notContains: [docB, docC]
785+
});
786+
787+
return (
788+
spec()
789+
.userListens(query1)
790+
.watchAcksFull(query1, 1000, docA, docB, docC)
791+
.expectEvents(query1, { added: [docA, docB, docC] })
792+
.disableNetwork()
793+
.expectEvents(query1, { fromCache: true })
794+
.enableNetwork()
795+
.restoreListen(query1, 'resume-token-1000')
796+
.watchAcks(query1)
797+
// While this client was disconnected, another client modified docB to no longer match the
798+
// query, deleted docC and added docD.
799+
.watchSends({ affects: [query1] }, docD)
800+
// Bloom Filter includes all the documents that match the query.
801+
.watchFilters([query1], [docA.key, docD.key], bloomFilterProto)
802+
.watchCurrents(query1, 'resume-token-2000')
803+
.watchSnapshots(2000)
804+
.expectEvents(query1, { added: [docD], fromCache: true })
805+
// Bloom Filter identifies that updatedDocB and docC no longer match the query, skips full
806+
// query and puts them into limbo directly.
807+
.expectLimboDocs(updatedDocB.key, docC.key) // updatedDocB and docC is now in limbo.
808+
);
809+
}
810+
);
634811
});

packages/firestore/test/unit/specs/limbo_spec.test.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -959,10 +959,10 @@ describeSpec('Limbo Documents:', [], () => {
959959
// While this client was disconnected, another client deleted all the
960960
// docAs replaced them with docBs. If Watch has to re-run the
961961
// underlying query when this client re-listens, Watch won't be able
962-
// to tell that docAs were deleted and will only send us existing
963-
// documents that changed since the resume token. This will cause it
964-
// to just send the docBs with an existence filter with a count of 3.
962+
// to tell that docAs were deleted and will only send us watch change
963+
// for new docs added since the resume token.
965964
.watchSends({ affects: [query1] }, docB1, docB2, docB3)
965+
// The existence filter will include the docBs with a count of 3.
966966
.watchFilters(
967967
[query1],
968968
[docB1.key, docB2.key, docB3.key],

0 commit comments

Comments
 (0)