Files

708 lines
23 KiB
TypeScript

/**
* @license
* Copyright 2017 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.
*/
import { expect } from 'chai';
import { SnapshotVersion } from '../../../src/core/snapshot_version';
import { RemoteTargetId, TargetId } from '../../../src/core/types';
import { TargetData, TargetPurpose } from '../../../src/local/target_data';
import { DocumentKeySet, documentKeySet } from '../../../src/model/collections';
import { ExistenceFilter } from '../../../src/remote/existence_filter';
import { RemoteEvent, TargetChange } from '../../../src/remote/remote_event';
import {
DocumentWatchChange,
ExistenceFilterChange,
WatchChangeAggregator,
WatchTargetChange,
WatchTargetChangeState
} from '../../../src/remote/watch_change';
import { ByteString } from '../../../src/util/byte_string';
import {
deletedDoc,
doc,
expectEqual,
keys,
targetData,
resumeTokenForSnapshot,
updateMapping,
version,
key,
forEachNumber,
addWatchTargets
} from '../../util/helpers';
import { TEST_DATABASE_ID } from '../local/persistence_test_helpers';
interface TargetMap {
[targetId: string]: TargetData;
}
interface PendingTargetResponses {
[targetId: string]: number;
}
function listens(...targetIds: TargetId[]): TargetMap {
const targets: TargetMap = {};
for (const target of targetIds) {
targets[target] = targetData(target, TargetPurpose.Listen, 'coll');
}
return targets;
}
function limboListens(...targetIds: TargetId[]): TargetMap {
const targets: TargetMap = {};
for (const target of targetIds) {
targets[target] = targetData(
target,
TargetPurpose.LimboResolution,
'coll/limbo'
);
}
return targets;
}
function expectTargetChangeEquals(
actual: TargetChange,
expected: TargetChange
): void {
expect(actual.current).to.equal(expected.current, 'TargetChange.current');
expect(actual.resumeToken).to.deep.equal(
expected.resumeToken,
'TargetChange.resumeToken'
);
expect(actual.addedDocuments.isEqual(expected.addedDocuments)).to.equal(
true,
'TargetChange.addedDocuments'
);
expect(actual.modifiedDocuments.isEqual(expected.modifiedDocuments)).to.equal(
true,
'TargetChange.modifiedDocuments'
);
expect(actual.removedDocuments.isEqual(expected.removedDocuments)).to.equal(
true,
'TargetChange.removedDocuments'
);
}
describe('RemoteEvent', () => {
function createAggregator(options: {
snapshotVersion: number;
targets?: TargetMap;
outstandingResponses?: PendingTargetResponses;
existingKeys?: DocumentKeySet;
changes?: Array<DocumentWatchChange | WatchTargetChange>;
}): WatchChangeAggregator {
const targetIds: RemoteTargetId[] = [];
if (options.targets) {
forEachNumber(options.targets, targetId => {
targetIds.push(targetId);
});
}
const aggregator = new WatchChangeAggregator({
getRemoteKeysForTarget: () => options.existingKeys || documentKeySet(),
getTargetDataForTarget: targetId =>
(options.targets
? options.targets[targetId]
: null) as TargetData<RemoteTargetId> | null,
getDatabaseId: () => TEST_DATABASE_ID
});
// Put all targets into the 'added' state
addWatchTargets(aggregator, targetIds);
// Move `outstandingResponses` targets into the 'pending' state
if (options.outstandingResponses) {
forEachNumber(options.outstandingResponses, (targetId, count) => {
for (let i = 0; i < count; ++i) {
aggregator.recordPendingTargetRequest(targetId);
}
});
}
if (options.changes) {
options.changes.forEach(change =>
change instanceof DocumentWatchChange
? aggregator.handleDocumentChange(change)
: aggregator.handleTargetChange(change)
);
}
aggregator.handleTargetChange(
new WatchTargetChange(
WatchTargetChangeState.NoChange,
targetIds,
resumeTokenForSnapshot(version(options.snapshotVersion))
)
);
return aggregator;
}
function createRemoteEvent(options: {
snapshotVersion: number;
targets?: TargetMap;
outstandingResponses?: PendingTargetResponses;
existingKeys?: DocumentKeySet;
changes?: Array<DocumentWatchChange | WatchTargetChange>;
}): RemoteEvent<RemoteTargetId> {
return createAggregator(options).createRemoteEvent(
version(options.snapshotVersion)
);
}
it('will accumulate document added and removed events', () => {
const targets = listens(1, 2, 3, 4, 5, 6);
const existingDoc = doc('docs/1', 1, { value: 1 });
const newDoc = doc('docs/2', 2, { value: 2 });
const change1 = new DocumentWatchChange(
[1, 2, 3],
[4, 5, 6],
existingDoc.key,
existingDoc
);
const change2 = new DocumentWatchChange([1, 4], [2, 6], newDoc.key, newDoc);
const existingKeys = keys(existingDoc);
const event = createRemoteEvent({
snapshotVersion: 3,
targets,
existingKeys,
changes: [change1, change2]
});
expectEqual(event.snapshotVersion, version(3));
expect(event.documentUpdates.size).to.equal(2);
expectEqual(event.documentUpdates.get(existingDoc.key), existingDoc);
expectEqual(event.documentUpdates.get(newDoc.key), newDoc);
expect(event.targetChanges.size).to.equal(6);
const mapping1 = updateMapping(version(3), [newDoc], [existingDoc], []);
expectTargetChangeEquals(event.targetChanges.get(1)!, mapping1);
const mapping2 = updateMapping(version(3), [], [existingDoc], []);
expectTargetChangeEquals(event.targetChanges.get(2)!, mapping2);
const mapping3 = updateMapping(version(3), [], [existingDoc], []);
expectTargetChangeEquals(event.targetChanges.get(3)!, mapping3);
const mapping4 = updateMapping(version(3), [newDoc], [], [existingDoc]);
expectTargetChangeEquals(event.targetChanges.get(4)!, mapping4);
const mapping5 = updateMapping(version(3), [], [], [existingDoc]);
expectTargetChangeEquals(event.targetChanges.get(5)!, mapping5);
const mapping6 = updateMapping(version(3), [], [], [existingDoc]);
expectTargetChangeEquals(event.targetChanges.get(6)!, mapping6);
});
it('will ignore events for pending targets', () => {
const doc1 = doc('docs/1', 1, { value: 1 });
const doc2 = doc('docs/2', 2, { value: 2 });
// Open listen for target 1
const targets = listens(1);
// We're waiting for the unlisten and listen ack.
const outstandingResponses = { 1: 2 };
const change1 = new DocumentWatchChange([1], [], doc1.key, doc1);
const change2 = new WatchTargetChange(WatchTargetChangeState.Removed, [1]);
const change3 = new WatchTargetChange(WatchTargetChangeState.Added, [1]);
const change4 = new DocumentWatchChange([1], [], doc2.key, doc2);
const event = createRemoteEvent({
snapshotVersion: 3,
targets,
outstandingResponses,
changes: [change1, change2, change3, change4]
});
expectEqual(event.snapshotVersion, version(3));
// Doc1 is ignored because the target was not active at the time, but for
// doc2 the target is active.
expect(event.documentUpdates.size).to.equal(1);
expectEqual(event.documentUpdates.get(doc2.key), doc2);
// Target 1 is ignored because it was removed
expect(event.targetChanges.size).to.equal(1);
});
it('will ignore events for removed targets', () => {
const doc1 = doc('docs/1', 1, { value: 1 });
// We're waiting for the removal ack.
const outstandingResponses = { 1: 1 };
const change1 = new DocumentWatchChange([1], [], doc1.key, doc1);
const change2 = new WatchTargetChange(WatchTargetChangeState.Removed, [1]);
const event = createRemoteEvent({
snapshotVersion: 3,
outstandingResponses,
changes: [change1, change2]
});
expectEqual(event.snapshotVersion, version(3));
// Doc 1 is ignored because it was not apart of an active target.
expect(event.documentUpdates.size).to.equal(0);
// Target 1 is ignored because it was removed
expect(event.targetChanges.size).to.equal(0);
});
it('will keep reset mapping even with updates', () => {
const doc1 = doc('docs/1', 1, { value: 1 });
const doc2 = doc('docs/2', 2, { value: 2 });
const doc3 = doc('docs/3', 3, { value: 3 });
// Need to listen at target 1 for this to work.
const targets = listens(1);
const change1 = new DocumentWatchChange([1], [], doc1.key, doc1);
// Reset stream, ignoring doc1
const change2 = new WatchTargetChange(WatchTargetChangeState.Reset, [1]);
// Add doc2, doc3
const change3 = new DocumentWatchChange([1], [], doc2.key, doc2);
const change4 = new DocumentWatchChange([1], [], doc3.key, doc3);
// Remove doc2 again, should not show up in reset mapping.
const change5 = new DocumentWatchChange([], [1], doc2.key, doc2);
const event = createRemoteEvent({
snapshotVersion: 3,
targets,
existingKeys: keys(doc1),
changes: [change1, change2, change3, change4, change5]
});
expectEqual(event.snapshotVersion, version(3));
expect(event.documentUpdates.size).to.equal(3);
expectEqual(event.documentUpdates.get(doc1.key), doc1);
expectEqual(event.documentUpdates.get(doc2.key), doc2);
expectEqual(event.documentUpdates.get(doc3.key), doc3);
expect(event.targetChanges.size).to.equal(1);
// Only doc3 is part of the new mapping.
const expected = updateMapping(version(3), [doc3], [], [doc1]);
expectTargetChangeEquals(event.targetChanges.get(1)!, expected);
});
it('will handle single reset', () => {
// Need to listen at target 1 for this to work.
const targets = listens(1);
// Reset target
const change = new WatchTargetChange(WatchTargetChangeState.Reset, [1]);
const event = createRemoteEvent({
snapshotVersion: 3,
targets,
changes: [change]
});
expectEqual(event.snapshotVersion, version(3));
expect(event.documentUpdates.size).to.equal(0);
expect(event.targetChanges.size).to.equal(1);
// Reset mapping is empty.
const expected = updateMapping(version(3), [], [], []);
expectTargetChangeEquals(event.targetChanges.get(1)!, expected);
});
it('will handle target add and removal in same batch', () => {
// Need to listen at target 1 for this to work.
const targets = listens(1, 2);
const doc1a = doc('docs/1', 1, { value: 1 });
const doc1b = doc('docs/1', 1, { value: 2 });
const change1 = new DocumentWatchChange([1], [2], doc1a.key, doc1a);
const change2 = new DocumentWatchChange([2], [1], doc1b.key, doc1b);
const event = createRemoteEvent({
snapshotVersion: 3,
targets,
existingKeys: keys('docs/1'),
changes: [change1, change2]
});
expectEqual(event.snapshotVersion, version(3));
expect(event.documentUpdates.size).to.equal(1);
expectEqual(event.documentUpdates.get(doc1b.key), doc1b);
expect(event.targetChanges.size).to.equal(2);
const mapping1 = updateMapping(version(3), [], [], [doc1b]);
expectTargetChangeEquals(event.targetChanges.get(1)!, mapping1);
const mapping2 = updateMapping(version(3), [], [doc1b], []);
expectTargetChangeEquals(event.targetChanges.get(2)!, mapping2);
});
it('target current change will mark the target current', () => {
const targets = listens(1);
const change = new WatchTargetChange(WatchTargetChangeState.Current, [1]);
const event = createRemoteEvent({
snapshotVersion: 3,
targets,
changes: [change]
});
expectEqual(event.snapshotVersion, version(3));
expect(event.documentUpdates.size).to.equal(0);
expect(event.targetChanges.size).to.equal(1);
const mapping = updateMapping(version(3), [], [], [], true);
expectTargetChangeEquals(event.targetChanges.get(1)!, mapping);
});
it('target added change will reset previous state', () => {
// We have open listens for targets 1 and 3.
const targets = listens(1, 3);
const outstandingResponses = { 1: 2, 2: 1 };
const doc1 = doc('docs/1', 1, { value: 1 });
const doc2 = doc('docs/2', 2, { value: 2 });
const change1 = new DocumentWatchChange([1, 3], [2], doc1.key, doc1);
const change2 = new WatchTargetChange(
WatchTargetChangeState.Current,
[1, 2, 3]
);
const change3 = new WatchTargetChange(WatchTargetChangeState.Removed, [1]);
const change4 = new WatchTargetChange(WatchTargetChangeState.Removed, [2]);
const change5 = new WatchTargetChange(WatchTargetChangeState.Added, [1]);
const change6 = new DocumentWatchChange([1], [3], doc2.key, doc2);
const event = createRemoteEvent({
snapshotVersion: 3,
targets,
outstandingResponses,
existingKeys: keys(doc2),
changes: [change1, change2, change3, change4, change5, change6]
});
expectEqual(event.snapshotVersion, version(3));
expect(event.documentUpdates.size).to.equal(2);
expectEqual(event.documentUpdates.get(doc1.key), doc1);
expectEqual(event.documentUpdates.get(doc2.key), doc2);
// target 1 and 3 are affected (1 because of re-add), target 2 is not
// because of remove.
expect(event.targetChanges.size).to.equal(2);
// doc1 was before the remove, so it does not show up in the mapping.
// Current was before the remove.
const mapping1 = updateMapping(version(3), [], [doc2], [], false);
expectTargetChangeEquals(event.targetChanges.get(1)!, mapping1);
// Doc1 was before the remove.
// Current was before the remove
const mapping3 = updateMapping(version(3), [doc1], [], [doc2], true);
expectTargetChangeEquals(event.targetChanges.get(3)!, mapping3);
});
it('no change will still mark the affected targets', () => {
const targets = listens(1);
const change = new WatchTargetChange(WatchTargetChangeState.NoChange, [1]);
const event = createRemoteEvent({
snapshotVersion: 3,
targets,
changes: [change]
});
expectEqual(event.snapshotVersion, version(3));
expect(event.documentUpdates.size).to.equal(0);
expect(event.targetChanges.size).to.equal(1);
const expected = updateMapping(version(3), [], [], [], false);
expectTargetChangeEquals(event.targetChanges.get(1)!, expected);
});
// TODO(b/272564458): Add test cases for existence filter with bloom filter,
// one will skip the re-query, one will yield false positive result and clears
// target mapping.
it('existence filters clears target mapping', () => {
const targets = listens(1, 2);
const doc1 = doc('docs/1', 1, { value: 1 });
const doc2 = doc('docs/2', 1, { value: 1 });
const change1 = new DocumentWatchChange([1, 2], [], doc1.key, doc1);
const change2 = new DocumentWatchChange([2], [], doc2.key, doc2);
const aggregator = createAggregator({
snapshotVersion: 3,
targets,
existingKeys: keys(doc1),
changes: [change1, change2]
});
let event = aggregator.createRemoteEvent(version(3));
expect(event.documentUpdates.size).to.equal(2);
expect(event.targetChanges.size).to.equal(2);
// The existence filter mismatch will remove the document from target 1,
// but not synthesize a document delete.
aggregator.handleExistenceFilter(
new ExistenceFilterChange(1, new ExistenceFilter(0))
);
event = aggregator.createRemoteEvent(version(3));
expect(event.documentUpdates.size).to.equal(0);
expect(event.targetMismatches.size).to.equal(1);
expect(event.targetMismatches.get(1)).to.equal(
TargetPurpose.ExistenceFilterMismatch
);
expect(event.targetChanges.size).to.equal(1);
const expected = updateMapping(
SnapshotVersion.min(),
[],
[],
[doc1],
false
);
expectTargetChangeEquals(event.targetChanges.get(1)!, expected);
});
it('existence filters removes current changes', () => {
const targets = listens(1);
const doc1 = doc('docs/1', 1, { value: 1 });
const addDoc = new DocumentWatchChange([1], [], doc1.key, doc1);
const markCurrent = new WatchTargetChange(
WatchTargetChangeState.Current,
[1],
ByteString.EMPTY_BYTE_STRING
);
const aggregator = createAggregator({
snapshotVersion: 3,
targets
});
aggregator.handleTargetChange(markCurrent);
aggregator.handleDocumentChange(addDoc);
// The existence filter mismatch will clear the previous target mapping,
// but not synthesize a document delete.
aggregator.handleExistenceFilter(
new ExistenceFilterChange(1, new ExistenceFilter(0))
);
const event = aggregator.createRemoteEvent(version(3));
expect(event.documentUpdates.size).to.equal(1);
expect(event.targetMismatches.size).to.equal(1);
expect(event.targetMismatches.get(1)).to.equal(
TargetPurpose.ExistenceFilterMismatch
);
expect(event.targetChanges.get(1)!.current).to.be.false;
});
it('handles document update', () => {
const targets = listens(1);
const doc1 = doc('docs/1', 1, { value: 1 });
const deletedDoc1 = deletedDoc('docs/1', 4);
const doc2 = doc('docs/2', 2, { value: 2 });
const updatedDoc2 = doc('docs/2', 3, { value: 2 });
const doc3 = doc('docs/3', 3, { value: 3 });
const change1 = new DocumentWatchChange([1], [], doc1.key, doc1);
const change2 = new DocumentWatchChange([1], [], doc2.key, doc2);
const aggregator = createAggregator({
snapshotVersion: 3,
targets,
existingKeys: keys(doc1, doc2),
changes: [change1, change2]
});
let event = aggregator.createRemoteEvent(version(3));
expectEqual(event.snapshotVersion, version(3));
expect(event.documentUpdates.size).to.equal(2);
expectEqual(event.documentUpdates.get(doc1.key), doc1);
expectEqual(event.documentUpdates.get(doc2.key), doc2);
aggregator.removeDocumentFromTarget(1, deletedDoc1.key, deletedDoc1);
aggregator.addDocumentToTarget(1, updatedDoc2);
aggregator.addDocumentToTarget(1, doc3);
event = aggregator.createRemoteEvent(version(3));
expectEqual(event.snapshotVersion, version(3));
expect(event.documentUpdates.size).to.equal(3);
// Doc 1 is replaced
const olddoc = event.documentUpdates.get(doc1.key);
expectEqual(olddoc, deletedDoc1);
// Doc 3 is new
expectEqual(event.documentUpdates.get(doc3.key), doc3);
// Target is unchanged
expect(event.targetChanges.size).to.equal(1);
const mapping1 = updateMapping(
version(3),
[doc3],
[updatedDoc2],
[deletedDoc1]
);
expectTargetChangeEquals(event.targetChanges.get(1)!, mapping1);
});
it('only raises events for updated targets', () => {
const targets = listens(1, 2);
const doc1 = doc('docs/1', 1, { value: 1 });
const doc2 = doc('docs/2', 2, { value: 2 });
const updatedDoc2 = doc('docs/2', 3, { value: 2 });
const change1 = new DocumentWatchChange([1], [], doc1.key, doc1);
const change2 = new DocumentWatchChange([2], [], doc2.key, doc2);
const aggregator = createAggregator({
snapshotVersion: 3,
targets,
existingKeys: keys(doc1, doc2),
changes: [change1, change2]
});
let event = aggregator.createRemoteEvent(version(2));
expect(event.documentUpdates.size).to.equal(2);
expect(event.targetChanges.size).to.equal(2);
aggregator.addDocumentToTarget(2, updatedDoc2);
event = aggregator.createRemoteEvent(version(2));
expect(event.documentUpdates.size).to.equal(1);
expect(event.targetChanges.size).to.equal(1);
const mapping1 = updateMapping(version(3), [], [updatedDoc2], []);
expectTargetChangeEquals(event.targetChanges.get(2)!, mapping1);
});
it('synthesizes deletes', () => {
const targets = limboListens(1);
const limboKey = key('coll/limbo');
const resolveLimboTarget = new WatchTargetChange(
WatchTargetChangeState.Current,
[1]
);
const event = createRemoteEvent({
snapshotVersion: 1,
targets,
changes: [resolveLimboTarget]
});
const expected = deletedDoc(
'coll/limbo',
event.snapshotVersion.toMicroseconds()
);
expectEqual(event.documentUpdates.get(limboKey), expected);
expect(event.resolvedLimboDocuments.has(limboKey)).to.be.true;
});
it("doesn't synthesize deletes in the wrong state", () => {
const targets = limboListens(1);
const limboKey = key('coll/limbo');
const wrongState = new WatchTargetChange(WatchTargetChangeState.NoChange, [
1
]);
const event = createRemoteEvent({
snapshotVersion: 1,
targets,
changes: [wrongState]
});
expect(event.documentUpdates.get(limboKey)).to.not.exist;
expect(event.resolvedLimboDocuments.has(limboKey)).to.be.false;
});
it('separates document updates', () => {
const updateTargetId = 1;
const newDoc = doc('docs/new', 1, { key: 'value' });
const existingDoc = doc('docs/existing', 1, { some: 'data' });
const newDocChange = new DocumentWatchChange(
[updateTargetId],
[],
newDoc.key,
newDoc
);
const existingDocChange = new DocumentWatchChange(
[updateTargetId],
[],
existingDoc.key,
existingDoc
);
const targets = listens(updateTargetId);
const event = createRemoteEvent({
snapshotVersion: 1,
targets,
existingKeys: keys(existingDoc),
changes: [newDocChange, existingDocChange]
});
const updateChange = event.targetChanges.get(updateTargetId)!;
expect(updateChange.addedDocuments.has(newDoc.key)).to.be.true;
expect(updateChange.addedDocuments.has(existingDoc.key)).to.be.false;
expect(updateChange.modifiedDocuments.has(newDoc.key)).to.be.false;
expect(updateChange.modifiedDocuments.has(existingDoc.key)).to.be.true;
});
it('tracks limbo documents', () => {
// Add 3 docs: 1 is limbo and non-limbo, 2 is limbo-only, 3 is non-limbo
const doc1 = doc('docs/1', 1, { key: 'value' });
const doc2 = doc('docs/2', 1, { key: 'value' });
const doc3 = doc('docs/3', 1, { key: 'value' });
// Target 2 is a limbo target
const docChange1 = new DocumentWatchChange([1, 2], [], doc1.key, doc1);
const docChange2 = new DocumentWatchChange([2], [], doc2.key, doc2);
const docChange3 = new DocumentWatchChange([1], [], doc3.key, doc3);
const targetsChange = new WatchTargetChange(
WatchTargetChangeState.Current,
[1, 2]
);
const targets = { ...listens(1), ...limboListens(2) };
const event = createRemoteEvent({
snapshotVersion: 1,
targets,
changes: [docChange1, docChange2, docChange3, targetsChange]
});
// Doc1 is in both limbo and non-limbo targets, therefore not tracked as limbo
expect(event.resolvedLimboDocuments.has(doc1.key)).to.be.false;
// Doc2 is only in the limbo target, so is tracked as a limbo document
expect(event.resolvedLimboDocuments.has(doc2.key)).to.be.true;
// Doc3 is only in the non-limbo target, therefore not tracked as limbo
expect(event.resolvedLimboDocuments.has(doc3.key)).to.be.false;
});
});