Skip to content

Fix for 8474 - Prevent a possible condition of slow snapshots, caused by a rapid series of document update(s) followed by a delete. #8595

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

Merged
merged 7 commits into from
Oct 30, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tasty-boxes-brake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@firebase/firestore": patch
---

Fix for Issue #8474 - Prevent a possible condition of slow snapshots, caused by a rapid series of document update(s) followed by a delete.
6 changes: 3 additions & 3 deletions packages/firestore/src/remote/watch_change.ts
Original file line number Diff line number Diff line change
Expand Up @@ -588,9 +588,9 @@ export class WatchChangeAggregator {
if (targetState.current && targetIsDocumentTarget(targetData.target)) {
// Document queries for document that don't exist can produce an empty
// result set. To update our local cache, we synthesize a document
// delete if we have not previously received the document. This
// resolves the limbo state of the document, removing it from
// limboDocumentRefs.
// delete if we have not previously received the document for this
// target. This resolves the limbo state of the document, removing it
// from limboDocumentRefs.
//
// TODO(dimond): Ideally we would have an explicit lookup target
// instead resulting in an explicit delete message and we could
Expand Down
168 changes: 87 additions & 81 deletions packages/firestore/test/unit/specs/limbo_spec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1173,7 +1173,7 @@ describeSpec('Limbo Documents:', [], () => {
);

specTest(
'Fix #8474 - Limbo resolution for documentC is removed even if documentC updates occurred earlier in the global snapshot window',
'Fix #8474 - Limbo resolution for document is removed even if document updates for the document occurred before documentDelete in the global snapshot window',
[],
() => {
// onSnapshot(fullQuery)
Expand All @@ -1199,7 +1199,7 @@ describeSpec('Limbo Documents:', [], () => {
})

// docC was deleted, this puts docC in limbo and causes a snapshot from cache (metadata-only change)
.watchSends({ removed: [fullQuery] }, docC)
.watchRemovesDoc(docC.key, fullQuery)
.watchCurrents(fullQuery, 'resume-token-1002')
.watchSnapshots(1002)
.expectLimboDocs(docC.key)
Expand All @@ -1221,19 +1221,19 @@ describeSpec('Limbo Documents:', [], () => {
.watchAcks(filterQuery)

// Watch responds to limboQueryC - docC was deleted
.watchSends({ affects: [limboQueryC] })
.watchDeletesDoc(docC.key, 1009, limboQueryC)
.watchCurrents(limboQueryC, 'resume-token-1009')
.watchSnapshots(1009, [limboQueryC, fullQuery])

// However, docC is still in limbo because there has not been a global snapshot
.expectLimboDocs(docC.key)

// Rapid events of document update and delete caused by application
.watchSends({ removed: [filterQuery] }, docA)
.watchRemovesDoc(docA.key, filterQuery)
.watchCurrents(filterQuery, 'resume-token-1004')
.watchSends({ affects: [filterQuery] }, docC)
.watchCurrents(filterQuery, 'resume-token-1005')
.watchSends({ removed: [filterQuery] }, docC)
.watchRemovesDoc(docC.key, filterQuery)
.watchSends({ affects: [filterQuery] }, docA2)
.watchCurrents(filterQuery, 'resume-token-1007')

Expand All @@ -1255,96 +1255,103 @@ describeSpec('Limbo Documents:', [], () => {
removed: [docC],
added: [docA2]
})

// No more expected events
.watchSnapshots(1100, [])
);
}
);

specTest('Fix #8474 - Watch sends deletedDoc on limbo resolution', [], () => {
// onSnapshot(fullQuery)
const fullQuery = query('collection');
specTest(
'Fix #8474 - Limbo resolution for document is removed even if document updates for the document occurred in the global snapshot window and no document delete was received for the limbo resolution query',
[],
() => {
// onSnapshot(fullQuery)
const fullQuery = query('collection');

// getDocs(filterQuery)
const filterQuery = query('collection', filter('included', '==', true));
// getDocs(filterQuery)
const filterQuery = query('collection', filter('included', '==', true));

const docA = doc('collection/a', 1000, { key: 'a', included: false });
const docA2 = doc('collection/a', 1007, { key: 'a', included: true });
const docC = doc('collection/c', 1002, { key: 'c', included: true });
const docCDeleted = deletedDoc('collection/c', 1009);
const docA = doc('collection/a', 1000, { key: 'a', included: false });
const docA2 = doc('collection/a', 1007, { key: 'a', included: true });
const docC = doc('collection/c', 1002, { key: 'c', included: true });

const limboQueryC = newQueryForPath(docC.key.path);
const limboQueryC = newQueryForPath(docC.key.path);

return (
spec()
// onSnapshot(fullQuery) - fullQuery is listening to documents in the collection for the full test
.userListens(fullQuery)
.watchAcksFull(fullQuery, 1001, docA, docC)
.expectEvents(fullQuery, {
fromCache: false,
added: [docA, docC]
})
return (
spec()
// onSnapshot(fullQuery) - fullQuery is listening to documents in the collection for the full test
.userListens(fullQuery)
.watchAcksFull(fullQuery, 1001, docA, docC)
.expectEvents(fullQuery, {
fromCache: false,
added: [docA, docC]
})

// docC was deleted, this puts docC in limbo and causes a snapshot from cache (metadata-only change)
.watchSends({ removed: [fullQuery] }, docC)
.watchCurrents(fullQuery, 'resume-token-1002')
.watchSnapshots(1002)
.expectLimboDocs(docC.key)
.expectEvents(fullQuery, {
fromCache: true
})
// docC was deleted, this puts docC in limbo and causes a snapshot from cache (metadata-only change)
.watchRemovesDoc(docC.key, fullQuery)
.watchCurrents(fullQuery, 'resume-token-1002')
.watchSnapshots(1002)
.expectLimboDocs(docC.key)
.expectEvents(fullQuery, {
fromCache: true
})

// User begins getDocs(filterQuery)
.userListensForGet(filterQuery)
// User begins getDocs(filterQuery)
.userListensForGet(filterQuery)

// getDocs(filterQuery) will not resolve on the snapshot from cache
.expectEvents(filterQuery, {
fromCache: true,
added: [docC]
})
// getDocs(filterQuery) will not resolve on the snapshot from cache
.expectEvents(filterQuery, {
fromCache: true,
added: [docC]
})

// Watch acks limbo and filter queries
.watchAcks(limboQueryC)
.watchAcks(filterQuery)
// Watch acks limbo and filter queries
.watchAcks(limboQueryC)
.watchAcks(filterQuery)

// Watch responds to limboQueryC - docC was deleted
.watchSends({ affects: [limboQueryC] }, docCDeleted)
.watchCurrents(limboQueryC, 'resume-token-1009')
.watchSnapshots(1009, [limboQueryC, fullQuery])
// Watch currents the limbo query, but did not send a document delete.
// This is and unexpected code path, but something that is called
// out as possible in the watch change aggregator.
.watchCurrents(limboQueryC, 'resume-token-1009')
.watchSnapshots(1009, [limboQueryC, fullQuery])

// However, docC is still in limbo because there has not been a global snapshot
.expectLimboDocs(docC.key)
// However, docC is still in limbo because there has not been a global snapshot
.expectLimboDocs(docC.key)

// Rapid events of document update and delete caused by application
.watchSends({ removed: [filterQuery] }, docA)
.watchCurrents(filterQuery, 'resume-token-1004')
.watchSends({ affects: [filterQuery] }, docC)
.watchCurrents(filterQuery, 'resume-token-1005')
.watchSends({ removed: [filterQuery] }, docC)
.watchSends({ affects: [filterQuery] }, docA2)
.watchCurrents(filterQuery, 'resume-token-1007')
// Rapid events of document update and delete caused by application
.watchRemovesDoc(docA.key, filterQuery)
.watchCurrents(filterQuery, 'resume-token-1004')
.watchSends({ affects: [filterQuery] }, docC)
.watchCurrents(filterQuery, 'resume-token-1005')
.watchRemovesDoc(docC.key, filterQuery)
.watchSends({ affects: [filterQuery] }, docA2)
.watchCurrents(filterQuery, 'resume-token-1007')

.watchSnapshots(1010, [fullQuery, limboQueryC])
.watchSnapshots(1010, [fullQuery, limboQueryC])

// All changes are current and we get a global snapshot
.watchSnapshots(1010, [])
// All changes are current and we get a global snapshot
.watchSnapshots(1010, [])

// Now docC is out of limbo
.expectLimboDocs()
.expectEvents(fullQuery, {
fromCache: false,
modified: [docA2],
removed: [docC]
})
// Now getDocs(filterQuery) can be resolved
.expectEvents(filterQuery, {
fromCache: false,
removed: [docC],
added: [docA2]
})
// Now docC is out of limbo
.expectLimboDocs()
.expectEvents(fullQuery, {
fromCache: false,
modified: [docA2],
removed: [docC]
})
// Now getDocs(filterQuery) can be resolved
.expectEvents(filterQuery, {
fromCache: false,
removed: [docC],
added: [docA2]
})

// No more expected events
.watchSnapshots(1100, [])
);
});
// No more expected events
.watchSnapshots(1100, [])
);
}
);

specTest(
'Fix #8474 - Handles code path of no ack for limbo resolution query before global snapshot',
Expand All @@ -1359,7 +1366,6 @@ describeSpec('Limbo Documents:', [], () => {
const docA = doc('collection/a', 1000, { key: 'a', included: false });
const docA2 = doc('collection/a', 1007, { key: 'a', included: true });
const docC = doc('collection/c', 1002, { key: 'c', included: true });
const docCDeleted = deletedDoc('collection/c', 1009);

const limboQueryC = newQueryForPath(docC.key.path);

Expand All @@ -1374,7 +1380,7 @@ describeSpec('Limbo Documents:', [], () => {
})

// docC was deleted, this puts docC in limbo and causes a snapshot from cache (metadata-only change)
.watchSends({ removed: [fullQuery] }, docC)
.watchRemovesDoc(docC.key, fullQuery)
.watchCurrents(fullQuery, 'resume-token-1002')
.watchSnapshots(1002)
.expectLimboDocs(docC.key)
Expand All @@ -1398,11 +1404,11 @@ describeSpec('Limbo Documents:', [], () => {
.expectLimboDocs(docC.key)

// Rapid events of document update and delete caused by application
.watchSends({ removed: [filterQuery] }, docA)
.watchRemovesDoc(docA.key, filterQuery)
.watchCurrents(filterQuery, 'resume-token-1004')
.watchSends({ affects: [filterQuery] }, docC)
.watchCurrents(filterQuery, 'resume-token-1005')
.watchSends({ removed: [filterQuery] }, docC)
.watchRemovesDoc(docC.key, filterQuery)
.watchSends({ affects: [filterQuery] }, docA2)
.watchCurrents(filterQuery, 'resume-token-1007')

Expand All @@ -1425,7 +1431,7 @@ describeSpec('Limbo Documents:', [], () => {
.watchAcks(limboQueryC)

// Watch responds to limboQueryC - docC was deleted
.watchSends({ affects: [limboQueryC] }, docCDeleted)
.watchDeletesDoc(docC.key, 1009, limboQueryC)
.watchCurrents(limboQueryC, 'resume-token-1009')
.watchSnapshots(1100, [limboQueryC])

Expand Down
19 changes: 18 additions & 1 deletion packages/firestore/test/unit/specs/spec_builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ import { forEach } from '../../../src/util/obj';
import { ObjectMap } from '../../../src/util/obj_map';
import { isNullOrUndefined } from '../../../src/util/types';
import { firestore } from '../../util/api_helpers';
import { TestSnapshotVersion } from '../../util/helpers';
import { deletedDoc, TestSnapshotVersion } from '../../util/helpers';

import { RpcError } from './spec_rpc_error';
import {
Expand Down Expand Up @@ -830,6 +830,23 @@ export class SpecBuilder {
return this;
}

watchDeletesDoc(
key: DocumentKey,
version: TestSnapshotVersion,
...targets: Query[]
): this {
this.nextStep();
this.currentStep = {
watchEntity: {
doc: SpecBuilder.docToSpec(
deletedDoc(key.path.canonicalString(), version)
),
removedTargets: targets.map(query => this.getTargetId(query))
}
};
return this;
}

watchFilters(
queries: Query[],
docs: DocumentKey[] = [],
Expand Down
13 changes: 10 additions & 3 deletions packages/firestore/test/util/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,12 +172,19 @@ export function doc(
}

export function deletedDoc(
keyStr: string,
keyStrOrDocumentKey: string | DocumentKey,
ver: TestSnapshotVersion
): MutableDocument {
return MutableDocument.newNoDocument(key(keyStr), version(ver)).setReadTime(
if (
keyStrOrDocumentKey instanceof String ||
typeof keyStrOrDocumentKey === 'string'
) {
keyStrOrDocumentKey = key(keyStrOrDocumentKey as string);
}
return MutableDocument.newNoDocument(
keyStrOrDocumentKey,
version(ver)
);
).setReadTime(version(ver));
}

export function unknownDoc(
Expand Down
Loading