Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Clear the lastLimboFreeSnapshot version on existence filter mismatch #5835

Merged
merged 9 commits into from Jan 4, 2022
5 changes: 5 additions & 0 deletions .changeset/unlucky-files-smoke.md
@@ -0,0 +1,5 @@
---
"@firebase/firestore": patch
---

Fixed an issue that can cause incomplete Query snapshots when the SDK is backgrounded during query execution.
47 changes: 26 additions & 21 deletions packages/firestore/src/local/local_store_impl.ts
Expand Up @@ -501,24 +501,34 @@ export function localStoreApplyRemoteEventToLocalCache(
})
);

const resumeToken = change.resumeToken;
// Update the resume token if the change includes one.
if (resumeToken.approximateByteSize() > 0) {
const newTargetData = oldTargetData
.withResumeToken(resumeToken, remoteVersion)
.withSequenceNumber(txn.currentSequenceNumber);
newTargetDataByTargetMap = newTargetDataByTargetMap.insert(
targetId,
newTargetData
let newTargetData = oldTargetData.withSequenceNumber(
txn.currentSequenceNumber
);
if (remoteEvent.targetMismatches.has(targetId)) {
newTargetData = newTargetData
.withResumeToken(
ByteString.EMPTY_BYTE_STRING,
SnapshotVersion.min()
)
.withLastLimboFreeSnapshotVersion(SnapshotVersion.min());
} else if (change.resumeToken.approximateByteSize() > 0) {
newTargetData = newTargetData.withResumeToken(
change.resumeToken,
remoteVersion
);
}

// Update the target data if there are target changes (or if
// sufficient time has passed since the last update).
if (shouldPersistTargetData(oldTargetData, newTargetData, change)) {
promises.push(
localStoreImpl.targetCache.updateTargetData(txn, newTargetData)
);
}
newTargetDataByTargetMap = newTargetDataByTargetMap.insert(
targetId,
newTargetData
);

// Update the target data if there are target changes (or if
// sufficient time has passed since the last update).
if (shouldPersistTargetData(oldTargetData, newTargetData, change)) {
promises.push(
localStoreImpl.targetCache.updateTargetData(txn, newTargetData)
);
}
});

Expand Down Expand Up @@ -675,11 +685,6 @@ function shouldPersistTargetData(
newTargetData: TargetData,
change: TargetChange
): boolean {
hardAssert(
newTargetData.resumeToken.approximateByteSize() > 0,
'Attempted to persist target data with no resume token'
);

// Always persist target data if we don't already have a resume token.
if (oldTargetData.resumeToken.approximateByteSize() === 0) {
return true;
Expand Down
6 changes: 3 additions & 3 deletions packages/firestore/test/unit/generate_spec_json.sh
Expand Up @@ -24,8 +24,8 @@ NPM_BIN_DIR="$(npm bin)"
TSNODE="$NPM_BIN_DIR/ts-node "
GENERATE_SPEC_JS="$DIR/generate_spec_json.js"

export TS_NODE_CACHE=NO
export TS_NODE_COMPILER_OPTIONS='{"module":"commonjs"}'
export TS_NODE_CACHE=NO
export TS_NODE_COMPILER_OPTIONS='{"module":"commonjs"}'
export TS_NODE_PROJECT="$DIR/../../tsconfig.json"

$TSNODE --require ../../index.node.ts $GENERATE_SPEC_JS "$@"
$TSNODE --require ../../src/index.node.ts $GENERATE_SPEC_JS "$@"
74 changes: 73 additions & 1 deletion packages/firestore/test/unit/local/local_store.test.ts
Expand Up @@ -93,6 +93,7 @@ import {
doc,
docAddedRemoteEvent,
docUpdateRemoteEvent,
existenceFilterEvent,
expectEqual,
filter,
key,
Expand Down Expand Up @@ -130,6 +131,7 @@ class LocalStoreTester {

constructor(
public localStore: LocalStore,
private readonly persistence: Persistence,
private readonly queryEngine: CountingQueryEngine,
readonly gcIsEager: boolean
) {
Expand Down Expand Up @@ -379,6 +381,30 @@ class LocalStoreTester {
return this;
}

toContainTargetData(
target: Target,
snapshotVersion: number,
lastLimboFreeSnapshotVersion: number,
resumeToken: ByteString
): LocalStoreTester {
this.promiseChain = this.promiseChain.then(async () => {
const targetData = await this.persistence.runTransaction(
'getTargetData',
'readonly',
txn => localStoreGetTargetData(this.localStore, txn, target)
);
expect(targetData!.snapshotVersion.isEqual(version(snapshotVersion))).to
.be.true;
expect(
targetData!.lastLimboFreeSnapshotVersion.isEqual(
version(lastLimboFreeSnapshotVersion)
)
).to.be.true;
expect(targetData!.resumeToken.isEqual(resumeToken)).to.be.true;
});
return this;
}

toReturnChanged(...docs: Document[]): LocalStoreTester {
this.promiseChain = this.promiseChain.then(() => {
debugAssert(
Expand Down Expand Up @@ -583,7 +609,12 @@ function genericLocalStoreTests(
});

function expectLocalStore(): LocalStoreTester {
return new LocalStoreTester(localStore, queryEngine, gcIsEager);
return new LocalStoreTester(
localStore,
persistence,
queryEngine,
gcIsEager
);
}

it('handles SetMutation', () => {
Expand Down Expand Up @@ -1876,6 +1907,47 @@ function genericLocalStoreTests(
}
});

// eslint-disable-next-line no-restricted-properties
(gcIsEager ? it.skip : it)(
'ignores target mapping after existence filter mismatch',
async () => {
const query1 = query('foo', filter('matches', '==', true));
const target = queryToTarget(query1);
const targetId = 2;

return (
expectLocalStore()
.afterAllocatingQuery(query1)
.toReturnTargetId(targetId)
// Persist a mapping with a single document
.after(
docAddedRemoteEvent(
doc('foo/a', 10, { matches: true }),
[targetId],
[],
[targetId]
)
)
.after(noChangeEvent(targetId, 10, byteStringFromString('foo')))
.after(localViewChanges(targetId, /* fromCache= */ false, {}))
.afterExecutingQuery(query1)
.toReturnChanged(doc('foo/a', 10, { matches: true }))
.toHaveRead({ documentsByKey: 1 })
.toContainTargetData(target, 10, 10, byteStringFromString('foo'))
// Create an existence filter mismatch and verify that the last limbo
// free snapshot version is deleted
.after(existenceFilterEvent(targetId, 2, 20))
.after(noChangeEvent(targetId, 20))
.toContainTargetData(target, 0, 0, ByteString.EMPTY_BYTE_STRING)
// Re-run the query as a collection scan
.afterExecutingQuery(query1)
.toReturnChanged(doc('foo/a', 10, { matches: true }))
.toHaveRead({ documentsByQuery: 1 })
.finish()
);
}
);

// eslint-disable-next-line no-restricted-properties
(gcIsEager ? it.skip : it)(
'queries include locally modified documents',
Expand Down
Expand Up @@ -211,4 +211,33 @@ describeSpec('Existence Filters:', [], () => {
})
);
});

specTest(
'Existence filter clears resume token',
['durable-persistence'],
() => {
// This is a test for https://github.com/firebase/firebase-android-sdk/issues/3249
// In this particular scenario, the user received an existence filter
// mismatch, but the SDK only cleared the target-to-document mapping and
// not the lastLimboFreeSnapshot version. This caused the SDK to resume
// the query but not include old documents.
const query1 = query('collection');
const doc1 = doc('collection/1', 1000, { v: 1 });
const doc2 = doc('collection/2', 1000, { v: 2 });
return (
spec()
.userListens(query1)
.watchAcksFull(query1, 1000, doc1, doc2)
.expectEvents(query1, { added: [doc1, doc2] })
.watchFilters([query1], doc1.key) // doc2 was deleted
.watchSnapshots(2000)
.expectEvents(query1, { fromCache: true })
// The SDK is unable to re-run the query, and does not remove doc2
.restart()
.userListens(query1)
// We check that the data is still consistent with the local cache
.expectEvents(query1, { added: [doc1, doc2], fromCache: true })
);
}
);
});
18 changes: 18 additions & 0 deletions packages/firestore/test/util/helpers.ts
Expand Up @@ -87,6 +87,7 @@ import {
LimitType as ProtoLimitType
} from '../../src/protos/firestore_bundle_proto';
import * as api from '../../src/protos/firestore_proto_api';
import { ExistenceFilter } from '../../src/remote/existence_filter';
import { RemoteEvent, TargetChange } from '../../src/remote/remote_event';
import {
JsonProtoSerializer,
Expand All @@ -98,6 +99,7 @@ import {
} from '../../src/remote/serializer';
import {
DocumentWatchChange,
ExistenceFilterChange,
WatchChangeAggregator,
WatchTargetChange,
WatchTargetChangeState
Expand Down Expand Up @@ -352,6 +354,22 @@ export function noChangeEvent(
return aggregator.createRemoteEvent(version(snapshotVersion));
}

export function existenceFilterEvent(
targetId: number,
count: number,
snapshotVersion: number
): RemoteEvent {
const aggregator = new WatchChangeAggregator({
getRemoteKeysForTarget: () => documentKeySet(),
getTargetDataForTarget: targetId =>
targetData(targetId, TargetPurpose.Listen, 'foo')
});
aggregator.handleExistenceFilter(
new ExistenceFilterChange(targetId, new ExistenceFilter(count))
);
return aggregator.createRemoteEvent(version(snapshotVersion));
}

export function docAddedRemoteEvent(
docOrDocs: MutableDocument | MutableDocument[],
updatedInTargets?: TargetId[],
Expand Down