Skip to content

Commit 279bfa2

Browse files
authored
Merge ef6f880 into 480d7d5
2 parents 480d7d5 + ef6f880 commit 279bfa2

File tree

6 files changed

+226
-1
lines changed

6 files changed

+226
-1
lines changed

integration/firestore/gulpfile.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ function copyTests() {
4545
testBase + '/integration/util/events_accumulator.ts',
4646
testBase + '/integration/util/helpers.ts',
4747
testBase + '/integration/util/settings.ts',
48+
testBase + '/integration/util/testing_hooks_util.ts',
4849
testBase + '/util/equality_matcher.ts',
4950
testBase + '/util/promise.ts'
5051
],

packages/firestore/src/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,3 +211,4 @@ export type { ByteString as _ByteString } from './util/byte_string';
211211
export { logWarn as _logWarn } from './util/log';
212212
export { EmptyAuthCredentialsProvider as _EmptyAuthCredentialsProvider } from './api/credentials';
213213
export { EmptyAppCheckTokenProvider as _EmptyAppCheckTokenProvider } from './api/credentials';
214+
export { TestingHooks as _TestingHooks } from './util/testing_hooks';

packages/firestore/src/remote/watch_change.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { logDebug } from '../util/log';
3434
import { primitiveComparator } from '../util/misc';
3535
import { SortedMap } from '../util/sorted_map';
3636
import { SortedSet } from '../util/sorted_set';
37+
import { TestingHooks } from '../util/testing_hooks';
3738

3839
import { ExistenceFilter } from './existence_filter';
3940
import { RemoteEvent, TargetChange } from './remote_event';
@@ -414,6 +415,10 @@ export class WatchChangeAggregator {
414415
// snapshot with `isFromCache:true`.
415416
this.resetTarget(targetId);
416417
this.pendingTargetResets = this.pendingTargetResets.add(targetId);
418+
TestingHooks.instance?.notifyOnExistenceFilterMismatch({
419+
localCacheCount: currentSize,
420+
existenceFilterCount: watchChange.existenceFilter.count
421+
});
417422
}
418423
}
419424
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* @license
3+
* Copyright 2023 Google LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
/**
19+
* Manages "testing hooks", hooks into the internals of the SDK to verify
20+
* internal state and events during integration tests. Do not use this class
21+
* except for testing purposes.
22+
*
23+
* There are two ways to retrieve the global singleton instance of this class:
24+
* 1. The `instance` property, which returns null if the global singleton
25+
* instance has not been created. Use this property if the caller should
26+
* "do nothing" if there are no testing hooks registered, such as when
27+
* delivering an event to notify registered callbacks.
28+
* 2. The `getOrCreateInstance()` method, which creates the global singleton
29+
* instance if it has not been created. Use this method if the instance is
30+
* needed to, for example, register a callback.
31+
*
32+
* @internal
33+
*/
34+
export class TestingHooks {
35+
private readonly onExistenceFilterMismatchCallbacks = new Map<
36+
Symbol,
37+
ExistenceFilterMismatchCallback
38+
>();
39+
40+
private constructor() {}
41+
42+
/**
43+
* Returns the singleton instance of this class, or null if it has not been
44+
* initialized.
45+
*/
46+
static get instance(): TestingHooks | null {
47+
return gTestingHooksSingletonInstance;
48+
}
49+
50+
/**
51+
* Returns the singleton instance of this class, creating it if is has never
52+
* been created before.
53+
*/
54+
static getOrCreateInstance(): TestingHooks {
55+
if (gTestingHooksSingletonInstance === null) {
56+
gTestingHooksSingletonInstance = new TestingHooks();
57+
}
58+
return gTestingHooksSingletonInstance;
59+
}
60+
61+
/**
62+
* Registers a callback to be notified when an existence filter mismatch
63+
* occurs in the Watch listen stream.
64+
*
65+
* The relative order in which callbacks are notified is unspecified; do not
66+
* rely on any particular ordering. If a given callback is registered multiple
67+
* times then it will be notified multiple times, once per registration.
68+
*
69+
* @param callback the callback to invoke upon existence filter mismatch.
70+
*
71+
* @return a function that, when called, unregisters the given callback; only
72+
* the first invocation of the returned function does anything; all subsequent
73+
* invocations do nothing.
74+
*/
75+
onExistenceFilterMismatch(
76+
callback: ExistenceFilterMismatchCallback
77+
): () => void {
78+
const key = Symbol();
79+
this.onExistenceFilterMismatchCallbacks.set(key, callback);
80+
return () => this.onExistenceFilterMismatchCallbacks.delete(key);
81+
}
82+
83+
/**
84+
* Invokes all currently-registered `onExistenceFilterMismatch` callbacks.
85+
* @param info Information about the existence filter mismatch.
86+
*/
87+
notifyOnExistenceFilterMismatch(info: ExistenceFilterMismatchInfo): void {
88+
this.onExistenceFilterMismatchCallbacks.forEach(callback => callback(info));
89+
}
90+
}
91+
92+
/**
93+
* Information about an existence filter mismatch, as specified to callbacks
94+
* registered with `TestingUtils.onExistenceFilterMismatch()`.
95+
*/
96+
export interface ExistenceFilterMismatchInfo {
97+
/** The number of documents that matched the query in the local cache. */
98+
localCacheCount: number;
99+
100+
/**
101+
* The number of documents that matched the query on the server, as specified
102+
* in the ExistenceFilter message's `count` field.
103+
*/
104+
existenceFilterCount: number;
105+
}
106+
107+
/**
108+
* The signature of callbacks registered with
109+
* `TestingUtils.onExistenceFilterMismatch()`.
110+
*/
111+
export type ExistenceFilterMismatchCallback = (
112+
info: ExistenceFilterMismatchInfo
113+
) => void;
114+
115+
/** The global singleton instance of `TestingHooks`. */
116+
let gTestingHooksSingletonInstance: TestingHooks | null = null;

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

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import {
6666
withTestDb
6767
} from '../util/helpers';
6868
import { USE_EMULATOR } from '../util/settings';
69+
import { captureExistenceFilterMismatches } from '../util/testing_hooks_util';
6970

7071
apiDescribe('Queries', (persistence: boolean) => {
7172
addEqualityMatcher();
@@ -2092,7 +2093,10 @@ apiDescribe('Queries', (persistence: boolean) => {
20922093
await new Promise(resolve => setTimeout(resolve, 10000));
20932094

20942095
// Resume the query and save the resulting snapshot for verification.
2095-
const snapshot2 = await getDocs(coll);
2096+
// Use some internal testing hooks to "capture" the existence filter
2097+
// mismatches to verify them.
2098+
const [existenceFilterMismatches, snapshot2] =
2099+
await captureExistenceFilterMismatches(() => getDocs(coll));
20962100

20972101
// Verify that the snapshot from the resumed query contains the expected
20982102
// documents; that is, that it contains the 50 documents that were _not_
@@ -2114,6 +2118,37 @@ apiDescribe('Queries', (persistence: boolean) => {
21142118
expectedDocumentIds
21152119
);
21162120
}
2121+
2122+
// Skip the verification of the existence filter mismatch when persistence
2123+
// is disabled because without persistence there is no resume token
2124+
// specified in the subsequent call to getDocs(), and, therefore, Watch
2125+
// will _not_ send an existence filter.
2126+
// TODO(b/272754156) Re-write this test using a snapshot listener instead
2127+
// of calls to getDocs() and remove this check for disabled persistence.
2128+
if (!persistence) {
2129+
return;
2130+
}
2131+
2132+
// Skip the verification of the existence filter mismatch when testing
2133+
// against the Firestore emulator because the Firestore emulator fails to
2134+
// to send an existence filter at all.
2135+
// TODO(b/270731363): Enable the verification of the existence filter
2136+
// mismatch once the Firestore emulator is fixed to send an existence
2137+
// filter.
2138+
if (USE_EMULATOR) {
2139+
return;
2140+
}
2141+
2142+
// Verify that Watch sent an existence filter with the correct counts when
2143+
// the query was resumed.
2144+
expect(
2145+
existenceFilterMismatches,
2146+
'existenceFilterMismatches'
2147+
).to.have.length(1);
2148+
const { localCacheCount, existenceFilterCount } =
2149+
existenceFilterMismatches[0];
2150+
expect(localCacheCount, 'localCacheCount').to.equal(100);
2151+
expect(existenceFilterCount, 'existenceFilterCount').to.equal(50);
21172152
});
21182153
}).timeout('90s');
21192154
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* @license
3+
* Copyright 2023 Google LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import { _TestingHooks as TestingHooks } from './firebase_export';
19+
20+
/**
21+
* Captures all existence filter mismatches in the Watch 'Listen' stream that
22+
* occur during the execution of the given code block.
23+
* @param callback The callback to invoke; during the invocation of this
24+
* callback all existence filter mismatches will be captured.
25+
* @return the captured existence filter mismatches and the result of awaiting
26+
* the given callback.
27+
*/
28+
export async function captureExistenceFilterMismatches<T>(
29+
callback: () => Promise<T>
30+
): Promise<[ExistenceFilterMismatchInfo[], T]> {
31+
const results: ExistenceFilterMismatchInfo[] = [];
32+
const onExistenceFilterMismatchCallback = (
33+
info: ExistenceFilterMismatchInfo
34+
): void => {
35+
results.push(info);
36+
};
37+
38+
const unregister =
39+
TestingHooks.getOrCreateInstance().onExistenceFilterMismatch(
40+
onExistenceFilterMismatchCallback
41+
);
42+
43+
let callbackResult: T;
44+
try {
45+
callbackResult = await callback();
46+
} finally {
47+
unregister();
48+
}
49+
50+
return [results, callbackResult];
51+
}
52+
53+
/**
54+
* Information about an existence filter mismatch, captured during an invocation
55+
* of `captureExistenceFilterMismatches()`.
56+
*
57+
* See the documentation of `TestingHooks.notifyOnExistenceFilterMismatch()`
58+
* for the meaning of these values.
59+
*
60+
* TODO: Delete this "interface" definition and instead use the one from
61+
* testing_hooks.ts. I tried to do this but couldn't figure out how to get it to
62+
* work in a way that survived bundling and minification.
63+
*/
64+
export interface ExistenceFilterMismatchInfo {
65+
localCacheCount: number;
66+
existenceFilterCount: number;
67+
}

0 commit comments

Comments
 (0)