Skip to content

Commit 41a2717

Browse files
Add spec tests for limit queries with limbo documents (#2033)
1 parent 576b00f commit 41a2717

File tree

3 files changed

+137
-3
lines changed

3 files changed

+137
-3
lines changed

packages/firestore/src/remote/watch_change.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { DocumentKey } from '../model/document_key';
2929
import { emptyByteString } from '../platform/platform';
3030
import { assert, fail } from '../util/assert';
3131
import { FirestoreError } from '../util/error';
32+
import { debug } from '../util/log';
3233
import { primitiveComparator } from '../util/misc';
3334
import * as objUtils from '../util/obj';
3435
import { SortedMap } from '../util/sorted_map';
@@ -253,6 +254,8 @@ export interface TargetMetadataProvider {
253254
getQueryDataForTarget(targetId: TargetId): QueryData | null;
254255
}
255256

257+
const LOG_TAG = 'WatchChangeAggregator';
258+
256259
/**
257260
* A helper class to accumulate watch changes into a RemoteEvent.
258261
*/
@@ -617,7 +620,11 @@ export class WatchChangeAggregator {
617620
* from watch.
618621
*/
619622
protected isActiveTarget(targetId: TargetId): boolean {
620-
return this.queryDataForActiveTarget(targetId) !== null;
623+
const targetActive = this.queryDataForActiveTarget(targetId) !== null;
624+
if (!targetActive) {
625+
debug(LOG_TAG, 'Detected inactive target', targetId);
626+
}
627+
return targetActive;
621628
}
622629

623630
/**

packages/firestore/test/unit/specs/limit_spec.test.ts

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
*/
1717

1818
import { Query } from '../../../src/core/query';
19-
import { deletedDoc, doc, filter, path } from '../../util/helpers';
19+
import { deletedDoc, doc, filter, orderBy, path } from '../../util/helpers';
2020

2121
import { describeSpec, specTest } from './describe_spec';
2222
import { client, spec } from './spec_builder';
@@ -184,6 +184,130 @@ describeSpec('Limits:', [], () => {
184184
});
185185
});
186186

187+
specTest(
188+
'Resumed limit queries exclude deleted documents ',
189+
['durable-persistence'],
190+
() => {
191+
// This test verifies that views for limit queries are updated even
192+
// when documents are deleted while the query is inactive.
193+
194+
const limitQuery = Query.atPath(path('collection'))
195+
.addOrderBy(orderBy('a'))
196+
.withLimit(1);
197+
const fullQuery = Query.atPath(path('collection')).addOrderBy(
198+
orderBy('a')
199+
);
200+
201+
const firstDocument = doc('collection/a', 1001, { a: 1 });
202+
const firstDocumentDeleted = deletedDoc('collection/a', 1003);
203+
const secondDocument = doc('collection/b', 1000, { a: 2 });
204+
205+
return (
206+
spec()
207+
.withGCEnabled(false)
208+
.userListens(limitQuery)
209+
.watchAcksFull(limitQuery, 1001, firstDocument)
210+
.expectEvents(limitQuery, { added: [firstDocument] })
211+
.userUnlistens(limitQuery)
212+
.watchRemoves(limitQuery)
213+
.userListens(fullQuery)
214+
.expectEvents(fullQuery, { added: [firstDocument], fromCache: true })
215+
.watchAcksFull(fullQuery, 1002, firstDocument, secondDocument)
216+
.expectEvents(fullQuery, { added: [secondDocument] })
217+
// Another client modified `firstDocument` and we lost access to it.
218+
// Watch sends us a remove, but doesn't tell us the new document state.
219+
// Since we don't know the state of the document, we mark it as limbo.
220+
.watchRemovesDoc(firstDocument.key, fullQuery)
221+
.watchSnapshots(1003)
222+
.expectEvents(fullQuery, { fromCache: true })
223+
.expectLimboDocs(firstDocument.key)
224+
.userUnlistens(fullQuery)
225+
// Since we stop listening to `fullQuery`, we disregard our attempt to
226+
// resolve the limbo state of `firstDocument`.
227+
.expectLimboDocs()
228+
.watchRemoves(fullQuery)
229+
// We restart the client, which clears the limbo target mapping in the
230+
// spec test runner. Without restarting, the runner assumes that each
231+
// limbo document is always assigned the same target ID. SyncEngine,
232+
// however, uses new target IDs if a document goes in and out of limbo.
233+
.restart()
234+
// We listen to the limit query again. Note that we include
235+
// `firstDocument` in the local result since we did not resolve its
236+
// limbo state.
237+
.userListens(limitQuery, 'resume-token-1001')
238+
.expectEvents(limitQuery, { added: [firstDocument], fromCache: true })
239+
.watchAcks(limitQuery)
240+
// Watch resumes the query from the provided resume token, but does
241+
// not guarantee to send us the removal of `firstDocument`. Instead,
242+
// we receive an existence filter, which indicates that our view is
243+
// out of sync.
244+
.watchSends({ affects: [limitQuery] }, secondDocument)
245+
.watchFilters([limitQuery], secondDocument.key)
246+
.watchSnapshots(1004)
247+
.expectActiveTargets({ query: limitQuery, resumeToken: '' })
248+
.watchRemoves(limitQuery)
249+
.watchAcksFull(limitQuery, 1005, secondDocument)
250+
// The snapshot after the existence filter mismatch triggers limbo
251+
// resolution. The local view still contains `firstDocument` and
252+
// hence we do not yet raise a new snapshot.
253+
.expectLimboDocs(firstDocument.key)
254+
.ackLimbo(1006, firstDocumentDeleted)
255+
.expectLimboDocs()
256+
// We raise the final snapshot when limbo resolution completes. We now
257+
// include `secondDocument`, which matches the backend result.
258+
.expectEvents(limitQuery, {
259+
added: [secondDocument],
260+
removed: [firstDocument]
261+
})
262+
);
263+
}
264+
);
265+
266+
specTest('Resumed limit queries use updated documents ', [], () => {
267+
// This test verifies that a resumed limit query will not contain documents
268+
// that fell out of the limit while the query was inactive.
269+
270+
const limitQuery = Query.atPath(path('collection'))
271+
.addOrderBy(orderBy('a'))
272+
.withLimit(1);
273+
const fullQuery = Query.atPath(path('collection')).addOrderBy(orderBy('a'));
274+
275+
const firstDocument = doc('collection/a', 2001, { a: 1 });
276+
const firstDocumentUpdated = doc('collection/a', 2003, { a: 3 });
277+
const secondDocument = doc('collection/c', 1000, { a: 2 });
278+
279+
return (
280+
spec()
281+
.withGCEnabled(false)
282+
// We issue a limit query with an orderBy constraint.
283+
.userListens(limitQuery)
284+
.watchAcksFull(limitQuery, 2001, firstDocument)
285+
.expectEvents(limitQuery, { added: [firstDocument] })
286+
.userUnlistens(limitQuery)
287+
.watchRemoves(limitQuery)
288+
// We issue a second query which adds `secondDocument` to the cache. We
289+
// also update `firstDocument` to sort after `secondDocument`.
290+
// `secondDocument` is now older than `firstDocument` but sorts before it
291+
// in the limit query.
292+
.userListens(fullQuery)
293+
.expectEvents(fullQuery, { added: [firstDocument], fromCache: true })
294+
.watchAcksFull(fullQuery, 2003, firstDocumentUpdated, secondDocument)
295+
.expectEvents(fullQuery, {
296+
added: [secondDocument],
297+
modified: [firstDocumentUpdated]
298+
})
299+
.userUnlistens(fullQuery)
300+
.watchRemoves(fullQuery)
301+
// Re-issue the limit query and verify that we return `secondDocument`
302+
// from cache.
303+
.userListens(limitQuery, 'resume-token-2001')
304+
.expectEvents(limitQuery, {
305+
added: [secondDocument],
306+
fromCache: true
307+
})
308+
);
309+
});
310+
187311
specTest('Multiple docs in limbo in full limit query', [], () => {
188312
const query1 = Query.atPath(path('collection')).withLimit(2);
189313
const query2 = Query.atPath(path('collection'));

packages/firestore/test/unit/specs/spec_test_runner.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1016,9 +1016,12 @@ abstract class TestRunner {
10161016
let actualLimboDocs = this.syncEngine.currentLimboDocs();
10171017
// Validate that each limbo doc has an expected active target
10181018
actualLimboDocs.forEach((key, targetId) => {
1019+
const targetIds: number[] = [];
1020+
obj.forEachNumber(this.expectedActiveTargets, id => targetIds.push(id));
10191021
expect(obj.contains(this.expectedActiveTargets, targetId)).to.equal(
10201022
true,
1021-
'Found limbo doc without an expected active target'
1023+
`Found limbo doc, but its target ID ${targetId} was not in the set of ` +
1024+
`expected active target IDs (${targetIds.join(', ')})`
10221025
);
10231026
});
10241027
for (const expectedLimboDoc of this.expectedLimboDocs) {

0 commit comments

Comments
 (0)