Skip to content

Commit 277f8e1

Browse files
authored
Optimize bloom filter application (#6992)
1 parent dee7744 commit 277f8e1

File tree

14 files changed

+137
-53
lines changed

14 files changed

+137
-53
lines changed

common/api-review/util.api.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,14 @@ export function createSubscribe<T>(executor: Executor<T>, onNoObservers?: Execut
9393
// @public
9494
export const decode: (token: string) => DecodedToken;
9595

96+
// Warning: (ae-missing-release-tag) "DecodeBase64StringError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
97+
//
98+
// @public
99+
export class DecodeBase64StringError extends Error {
100+
// (undocumented)
101+
readonly name = "DecodeBase64StringError";
102+
}
103+
96104
// Warning: (ae-missing-release-tag) "deepCopy" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
97105
//
98106
// @public

packages/firestore/src/model/path.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -163,11 +163,6 @@ abstract class BasePath<B extends BasePath<B>> {
163163
return this.segments.slice(this.offset, this.limit());
164164
}
165165

166-
// TODO(Mila): Use database info and toString() to get full path instead.
167-
toFullPath(): string {
168-
return this.segments.join('/');
169-
}
170-
171166
static comparator<T extends BasePath<T>>(
172167
p1: BasePath<T>,
173168
p2: BasePath<T>

packages/firestore/src/platform/base64.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,24 @@
1515
* limitations under the License.
1616
*/
1717

18+
import { Base64DecodeError } from '../util/base64_decode_error';
19+
1820
// This file is only used under ts-node.
1921
// eslint-disable-next-line @typescript-eslint/no-require-imports
2022
const platform = require(`./${process.env.TEST_PLATFORM ?? 'node'}/base64`);
2123

2224
/** Converts a Base64 encoded string to a binary string. */
2325
export function decodeBase64(encoded: string): string {
24-
return platform.decodeBase64(encoded);
26+
const decoded = platform.decodeBase64(encoded);
27+
28+
// A quick sanity check as node and rn will not throw error if input is an
29+
// invalid base64 string, e.g., "A===".
30+
const expectedEncodedLength = 4 * Math.ceil(decoded.length / 3);
31+
if (encoded.length !== expectedEncodedLength) {
32+
throw new Base64DecodeError('Invalid base64 string');
33+
}
34+
35+
return decoded;
2536
}
2637

2738
/** Converts a binary string to a Base64 encoded string. */

packages/firestore/src/platform/browser/base64.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,19 @@
1515
* limitations under the License.
1616
*/
1717

18+
import { Base64DecodeError } from '../../util/base64_decode_error';
19+
1820
/** Converts a Base64 encoded string to a binary string. */
1921
export function decodeBase64(encoded: string): string {
20-
return atob(encoded);
22+
try {
23+
return atob(encoded);
24+
} catch (e) {
25+
if (e instanceof DOMException) {
26+
throw new Base64DecodeError('Invalid base64 string: ' + e);
27+
} else {
28+
throw e;
29+
}
30+
}
2131
}
2232

2333
/** Converts a binary string to a Base64 encoded string. */

packages/firestore/src/platform/rn/base64.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,21 +15,31 @@
1515
* limitations under the License.
1616
*/
1717

18-
import { base64 } from '@firebase/util';
18+
import { base64, DecodeBase64StringError } from '@firebase/util';
19+
20+
import { Base64DecodeError } from '../../util/base64_decode_error';
1921

2022
// WebSafe uses a different URL-encoding safe alphabet that doesn't match
2123
// the encoding used on the backend.
2224
const WEB_SAFE = false;
2325

2426
/** Converts a Base64 encoded string to a binary string. */
2527
export function decodeBase64(encoded: string): string {
26-
return String.fromCharCode.apply(
27-
null,
28-
// We use `decodeStringToByteArray()` instead of `decodeString()` since
29-
// `decodeString()` returns Unicode strings, which doesn't match the values
30-
// returned by `atob()`'s Latin1 representation.
31-
base64.decodeStringToByteArray(encoded, WEB_SAFE)
32-
);
28+
try {
29+
return String.fromCharCode.apply(
30+
null,
31+
// We use `decodeStringToByteArray()` instead of `decodeString()` since
32+
// `decodeString()` returns Unicode strings, which doesn't match the values
33+
// returned by `atob()`'s Latin1 representation.
34+
base64.decodeStringToByteArray(encoded, WEB_SAFE)
35+
);
36+
} catch (e) {
37+
if (e instanceof DecodeBase64StringError) {
38+
throw new Base64DecodeError('Invalid base64 string: ' + e);
39+
} else {
40+
throw e;
41+
}
42+
}
3343
}
3444

3545
/** Converts a binary string to a Base64 encoded string. */

packages/firestore/src/remote/datastore.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import {
5959
*/
6060
export abstract class Datastore {
6161
abstract terminate(): void;
62+
abstract serializer: JsonProtoSerializer;
6263
}
6364

6465
/**

packages/firestore/src/remote/remote_store.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,8 @@ function startWatchStream(remoteStoreImpl: RemoteStoreImpl): void {
375375
getRemoteKeysForTarget: targetId =>
376376
remoteStoreImpl.remoteSyncer.getRemoteKeysForTarget!(targetId),
377377
getTargetDataForTarget: targetId =>
378-
remoteStoreImpl.listenTargets.get(targetId) || null
378+
remoteStoreImpl.listenTargets.get(targetId) || null,
379+
getDatabaseId: () => remoteStoreImpl.datastore.serializer.databaseId
379380
});
380381
ensureWatchStream(remoteStoreImpl).start();
381382
remoteStoreImpl.onlineStateTracker.handleWatchStreamStart();

packages/firestore/src/remote/watch_change.ts

Lines changed: 35 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
* limitations under the License.
1616
*/
1717

18+
import { DatabaseId } from '../core/database_info';
1819
import { SnapshotVersion } from '../core/snapshot_version';
1920
import { targetIsDocumentTarget } from '../core/target';
2021
import { TargetId } from '../core/types';
@@ -29,6 +30,7 @@ import { MutableDocument } from '../model/document';
2930
import { DocumentKey } from '../model/document_key';
3031
import { normalizeByteString } from '../model/normalize';
3132
import { debugAssert, fail, hardAssert } from '../util/assert';
33+
import { Base64DecodeError } from '../util/base64_decode_error';
3234
import { ByteString } from '../util/byte_string';
3335
import { FirestoreError } from '../util/error';
3436
import { logDebug, logWarn } from '../util/log';
@@ -253,6 +255,11 @@ export interface TargetMetadataProvider {
253255
* has become inactive
254256
*/
255257
getTargetDataForTarget(targetId: TargetId): TargetData | null;
258+
259+
/**
260+
* Returns the database ID of the Firestore instance.
261+
*/
262+
getDatabaseId(): DatabaseId;
256263
}
257264

258265
const LOG_TAG = 'WatchChangeAggregator';
@@ -416,8 +423,7 @@ export class WatchChangeAggregator {
416423
if (currentSize !== expectedCount) {
417424
// Apply bloom filter to identify and mark removed documents.
418425
const bloomFilterApplied = this.applyBloomFilter(
419-
watchChange.existenceFilter,
420-
targetId,
426+
watchChange,
421427
currentSize
422428
);
423429
if (!bloomFilterApplied) {
@@ -433,12 +439,11 @@ export class WatchChangeAggregator {
433439

434440
/** Returns whether a bloom filter removed the deleted documents successfully. */
435441
private applyBloomFilter(
436-
existenceFilter: ExistenceFilter,
437-
targetId: number,
442+
watchChange: ExistenceFilterChange,
438443
currentCount: number
439444
): boolean {
440-
const unchangedNames = existenceFilter.unchangedNames;
441-
const expectedCount = existenceFilter.count;
445+
const { unchangedNames, count: expectedCount } =
446+
watchChange.existenceFilter;
442447

443448
if (!unchangedNames || !unchangedNames.bits) {
444449
return false;
@@ -449,17 +454,22 @@ export class WatchChangeAggregator {
449454
hashCount = 0
450455
} = unchangedNames;
451456

452-
// TODO(Mila): Remove this validation, add try catch to normalizeByteString.
453-
if (typeof bitmap === 'string') {
454-
const isValidBitmap = this.isValidBase64String(bitmap);
455-
if (!isValidBitmap) {
456-
logWarn('Invalid base64 string. Applying bloom filter failed.');
457+
let normalizedBitmap: Uint8Array;
458+
try {
459+
normalizedBitmap = normalizeByteString(bitmap).toUint8Array();
460+
} catch (err) {
461+
if (err instanceof Base64DecodeError) {
462+
logWarn(
463+
'Decoding the base64 bloom filter in existence filter failed (' +
464+
err.message +
465+
'); ignoring the bloom filter and falling back to full re-query.'
466+
);
457467
return false;
468+
} else {
469+
throw err;
458470
}
459471
}
460472

461-
const normalizedBitmap = normalizeByteString(bitmap).toUint8Array();
462-
463473
let bloomFilter: BloomFilter;
464474
try {
465475
// BloomFilter throws error if the inputs are invalid.
@@ -474,37 +484,36 @@ export class WatchChangeAggregator {
474484
}
475485

476486
const removedDocumentCount = this.filterRemovedDocuments(
477-
bloomFilter,
478-
targetId
487+
watchChange.targetId,
488+
bloomFilter
479489
);
480490

481491
return expectedCount === currentCount - removedDocumentCount;
482492
}
483493

484-
// TODO(Mila): Move the validation into normalizeByteString.
485-
private isValidBase64String(value: string): boolean {
486-
const regExp = new RegExp(
487-
'^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$'
488-
);
489-
return regExp.test(value);
490-
}
491-
492494
/**
493495
* Filter out removed documents based on bloom filter membership result and
494496
* return number of documents removed.
495497
*/
496498
private filterRemovedDocuments(
497-
bloomFilter: BloomFilter,
498-
targetId: number
499+
targetId: number,
500+
bloomFilter: BloomFilter
499501
): number {
500502
const existingKeys = this.metadataProvider.getRemoteKeysForTarget(targetId);
501503
let removalCount = 0;
504+
502505
existingKeys.forEach(key => {
503-
if (!bloomFilter.mightContain(key.path.toFullPath())) {
506+
const databaseId = this.metadataProvider.getDatabaseId();
507+
const documentPath = `projects/${databaseId.projectId}/databases/${
508+
databaseId.database
509+
}/documents/${key.path.canonicalString()}`;
510+
511+
if (!bloomFilter.mightContain(documentPath)) {
504512
this.removeDocumentFromTarget(targetId, key, /*updatedDocument=*/ null);
505513
removalCount++;
506514
}
507515
});
516+
508517
return removalCount;
509518
}
510519

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
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+
* An error encountered while decoding base64 string.
20+
*/
21+
export class Base64DecodeError extends Error {
22+
readonly name = 'Base64DecodeError';
23+
}

packages/firestore/test/lite/integration.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -852,7 +852,7 @@ describe('DocumentSnapshot', () => {
852852

853853
it('returns Bytes', () => {
854854
return withTestDocAndInitialData(
855-
{ bytes: Bytes.fromBase64String('aa') },
855+
{ bytes: Bytes.fromBase64String('aa==') },
856856
async docRef => {
857857
const docSnap = await getDoc(docRef);
858858
const bytes = docSnap.get('bytes');

packages/firestore/test/unit/local/local_store.test.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1273,7 +1273,8 @@ function genericLocalStoreTests(
12731273
);
12741274
const aggregator = new WatchChangeAggregator({
12751275
getRemoteKeysForTarget: () => documentKeySet(),
1276-
getTargetDataForTarget: () => targetData
1276+
getTargetDataForTarget: () => targetData,
1277+
getDatabaseId: () => persistenceHelpers.TEST_DATABASE_ID
12771278
});
12781279
aggregator.handleTargetChange(watchChange);
12791280
const remoteEvent = aggregator.createRemoteEvent(version(1000));
@@ -1313,7 +1314,8 @@ function genericLocalStoreTests(
13131314
);
13141315
const aggregator1 = new WatchChangeAggregator({
13151316
getRemoteKeysForTarget: () => documentKeySet(),
1316-
getTargetDataForTarget: () => targetData
1317+
getTargetDataForTarget: () => targetData,
1318+
getDatabaseId: () => persistenceHelpers.TEST_DATABASE_ID
13171319
});
13181320
aggregator1.handleTargetChange(watchChange1);
13191321
const remoteEvent1 = aggregator1.createRemoteEvent(version(1000));
@@ -1326,7 +1328,8 @@ function genericLocalStoreTests(
13261328
);
13271329
const aggregator2 = new WatchChangeAggregator({
13281330
getRemoteKeysForTarget: () => documentKeySet(),
1329-
getTargetDataForTarget: () => targetData
1331+
getTargetDataForTarget: () => targetData,
1332+
getDatabaseId: () => persistenceHelpers.TEST_DATABASE_ID
13301333
});
13311334
aggregator2.handleTargetChange(watchChange2);
13321335
const remoteEvent2 = aggregator2.createRemoteEvent(version(2000));

packages/firestore/test/unit/remote/remote_event.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
key,
4444
forEachNumber
4545
} from '../../util/helpers';
46+
import { TEST_DATABASE_ID } from '../local/persistence_test_helpers';
4647

4748
interface TargetMap {
4849
[targetId: string]: TargetData;
@@ -110,11 +111,11 @@ describe('RemoteEvent', () => {
110111
targetIds.push(targetId);
111112
});
112113
}
113-
114114
const aggregator = new WatchChangeAggregator({
115115
getRemoteKeysForTarget: () => options.existingKeys || documentKeySet(),
116116
getTargetDataForTarget: targetId =>
117-
options.targets ? options.targets[targetId] : null
117+
options.targets ? options.targets[targetId] : null,
118+
getDatabaseId: () => TEST_DATABASE_ID
118119
});
119120

120121
if (options.outstandingResponses) {
@@ -155,6 +156,7 @@ describe('RemoteEvent', () => {
155156
version(options.snapshotVersion)
156157
);
157158
}
159+
158160
it('will accumulate document added and removed events', () => {
159161
const targets = listens(1, 2, 3, 4, 5, 6);
160162

packages/firestore/test/util/helpers.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,8 @@ export function noChangeEvent(
417417
const aggregator = new WatchChangeAggregator({
418418
getRemoteKeysForTarget: () => documentKeySet(),
419419
getTargetDataForTarget: targetId =>
420-
targetData(targetId, TargetPurpose.Listen, 'foo')
420+
targetData(targetId, TargetPurpose.Listen, 'foo'),
421+
getDatabaseId: () => TEST_DATABASE_ID
421422
});
422423
aggregator.handleTargetChange(
423424
new WatchTargetChange(
@@ -439,7 +440,8 @@ export function existenceFilterEvent(
439440
const aggregator = new WatchChangeAggregator({
440441
getRemoteKeysForTarget: () => syncedKeys,
441442
getTargetDataForTarget: targetId =>
442-
targetData(targetId, TargetPurpose.Listen, 'foo')
443+
targetData(targetId, TargetPurpose.Listen, 'foo'),
444+
getDatabaseId: () => TEST_DATABASE_ID
443445
});
444446
aggregator.handleExistenceFilter(
445447
new ExistenceFilterChange(
@@ -476,7 +478,8 @@ export function docAddedRemoteEvent(
476478
} else {
477479
return null;
478480
}
479-
}
481+
},
482+
getDatabaseId: () => TEST_DATABASE_ID
480483
});
481484

482485
let version = SnapshotVersion.min();
@@ -523,7 +526,8 @@ export function docUpdateRemoteEvent(
523526
? TargetPurpose.LimboResolution
524527
: TargetPurpose.Listen;
525528
return targetData(targetId, purpose, doc.key.toString());
526-
}
529+
},
530+
getDatabaseId: () => TEST_DATABASE_ID
527531
});
528532
aggregator.handleDocumentChange(docChange);
529533
return aggregator.createRemoteEvent(doc.version);

0 commit comments

Comments
 (0)