Skip to content

Commit 5c338d1

Browse files
authored
b/72533250: Fix issue with limbo resolutions triggering incorrect manufactured deletes. (#1014)
This fixes an issue occurring when a limbo target receives a documentUpdate, then a global snapshot, and then a CURRENT. Because there was a global snapshot before the CURRENT, WatchChangeAggregator has no pending document updates and calls SyncEngine.targetContainsDocument() to see if we previously got any document from the backend for the target. See: https://github.com/firebase/firebase-js-sdk/blob/6905339235ad801291edc696dd75a08e80647f5b/packages/firestore/src/remote/watch_change.ts#L422 Prior to this change, targetContainsDocument() returned false because it relies on our Views to track the contents of the target, and we don't have Views for limbo targets. Thus WatchChangeAggregator incorrectly manufactures a NoDocument document update which deletes data from our cache. The fix is to have SyncEngine track the fact that we did indeed get a document for the limbo resolution and return true from targetContainsDocument().
1 parent 6905339 commit 5c338d1

File tree

2 files changed

+132
-13
lines changed

2 files changed

+132
-13
lines changed

packages/firestore/src/core/sync_engine.ts

+62-8
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { assert, fail } from '../util/assert';
3636
import { FirestoreError } from '../util/error';
3737
import * as log from '../util/log';
3838
import { AnyJs, primitiveComparator } from '../util/misc';
39+
import * as objUtils from '../util/obj';
3940
import { ObjectMap } from '../util/obj_map';
4041
import { Deferred } from '../util/promise';
4142
import { SortedMap } from '../util/sorted_map';
@@ -92,6 +93,19 @@ class QueryView {
9293
) {}
9394
}
9495

96+
/** Tracks a limbo resolution. */
97+
class LimboResolution {
98+
constructor(public key: DocumentKey) {}
99+
100+
/**
101+
* Set to true once we've received a document. This is used in
102+
* targetContainsDocument() and ultimately used by WatchChangeAggregator to
103+
* decide whether it needs to manufacture a delete event for the target once
104+
* the target is CURRENT.
105+
*/
106+
receivedDocument: boolean;
107+
}
108+
95109
/**
96110
* SyncEngine is the central controller in the client SDK architecture. It is
97111
* the glue code between the EventManager, LocalStore, and RemoteStore. Some of
@@ -117,7 +131,9 @@ export class SyncEngine implements RemoteSyncer {
117131
private limboTargetsByKey = new SortedMap<DocumentKey, TargetId>(
118132
DocumentKey.comparator
119133
);
120-
private limboKeysByTarget: { [targetId: number]: DocumentKey } = {};
134+
private limboResolutionsByTarget: {
135+
[targetId: number]: LimboResolution;
136+
} = {};
121137
private limboDocumentRefs = new ReferenceSet();
122138
private limboCollector = new EagerGarbageCollector();
123139
/** Stores user completion handlers, indexed by User and BatchId. */
@@ -301,6 +317,38 @@ export class SyncEngine implements RemoteSyncer {
301317
applyRemoteEvent(remoteEvent: RemoteEvent): Promise<void> {
302318
this.assertSubscribed('applyRemoteEvent()');
303319

320+
// Update `receivedDocument` as appropriate for any limbo targets.
321+
objUtils.forEach(remoteEvent.targetChanges, (targetId, targetChange) => {
322+
const limboResolution = this.limboResolutionsByTarget[targetId];
323+
if (limboResolution) {
324+
// Since this is a limbo resolution lookup, it's for a single document
325+
// and it could be added, modified, or removed, but not a combination.
326+
assert(
327+
targetChange.addedDocuments.size +
328+
targetChange.modifiedDocuments.size +
329+
targetChange.removedDocuments.size <=
330+
1,
331+
'Limbo resolution for single document contains multiple changes.'
332+
);
333+
if (targetChange.addedDocuments.size > 0) {
334+
limboResolution.receivedDocument = true;
335+
} else if (targetChange.modifiedDocuments.size > 0) {
336+
assert(
337+
limboResolution.receivedDocument,
338+
'Received change for limbo target document without add.'
339+
);
340+
} else if (targetChange.removedDocuments.size > 0) {
341+
assert(
342+
limboResolution.receivedDocument,
343+
'Received remove for limbo target document without add.'
344+
);
345+
limboResolution.receivedDocument = false;
346+
} else {
347+
// This was probably just a CURRENT targetChange or similar.
348+
}
349+
}
350+
});
351+
304352
return this.localStore.applyRemoteEvent(remoteEvent).then(changes => {
305353
return this.emitNewSnapsAndNotifyLocalStore(changes, remoteEvent);
306354
});
@@ -327,12 +375,13 @@ export class SyncEngine implements RemoteSyncer {
327375

328376
rejectListen(targetId: TargetId, err: FirestoreError): Promise<void> {
329377
this.assertSubscribed('rejectListens()');
330-
const limboKey = this.limboKeysByTarget[targetId];
378+
const limboResolution = this.limboResolutionsByTarget[targetId];
379+
const limboKey = limboResolution && limboResolution.key;
331380
if (limboKey) {
332381
// Since this query failed, we won't want to manually unlisten to it.
333382
// So go ahead and remove it from bookkeeping.
334383
this.limboTargetsByKey = this.limboTargetsByKey.remove(limboKey);
335-
delete this.limboKeysByTarget[targetId];
384+
delete this.limboResolutionsByTarget[targetId];
336385

337386
// TODO(klimt): We really only should do the following on permission
338387
// denied errors, but we don't have the cause code here.
@@ -476,7 +525,7 @@ export class SyncEngine implements RemoteSyncer {
476525
log.debug(LOG_TAG, 'New document in limbo: ' + key);
477526
const limboTargetId = this.targetIdGenerator.next();
478527
const query = Query.atPath(key.path);
479-
this.limboKeysByTarget[limboTargetId] = key;
528+
this.limboResolutionsByTarget[limboTargetId] = new LimboResolution(key);
480529
this.remoteStore.listen(
481530
new QueryData(query, limboTargetId, QueryPurpose.LimboResolution)
482531
);
@@ -501,7 +550,7 @@ export class SyncEngine implements RemoteSyncer {
501550
}
502551
this.remoteStore.unlisten(limboTargetId);
503552
this.limboTargetsByKey = this.limboTargetsByKey.remove(key);
504-
delete this.limboKeysByTarget[limboTargetId];
553+
delete this.limboResolutionsByTarget[limboTargetId];
505554
});
506555
})
507556
.toPromise();
@@ -588,8 +637,13 @@ export class SyncEngine implements RemoteSyncer {
588637
}
589638

590639
getRemoteKeysForTarget(targetId: TargetId): DocumentKeySet {
591-
return this.queryViewsByTarget[targetId]
592-
? this.queryViewsByTarget[targetId].view.syncedDocuments
593-
: documentKeySet();
640+
const limboResolution = this.limboResolutionsByTarget[targetId];
641+
if (limboResolution && limboResolution.receivedDocument) {
642+
return documentKeySet().add(limboResolution.key);
643+
} else {
644+
return this.queryViewsByTarget[targetId]
645+
? this.queryViewsByTarget[targetId].view.syncedDocuments
646+
: documentKeySet();
647+
}
594648
}
595649
}

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

+70-5
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ describeSpec('Limbo Documents:', [], () => {
188188
});
189189

190190
// Regression test for b/72533250.
191-
specTest('Limbo documents handle receiving ack and then current', [], () => {
191+
specTest('Limbo resolution handles snapshot before CURRENT', [], () => {
192192
const fullQuery = Query.atPath(path('collection'));
193193
const limitQuery = Query.atPath(path('collection'))
194194
.addFilter(filter('include', '==', true))
@@ -236,10 +236,7 @@ describeSpec('Limbo Documents:', [], () => {
236236
.watchAcks(docBQuery)
237237
.watchSends({ affects: [docBQuery] }, docB)
238238

239-
// TODO(b/72533250): If you uncomment this totally valid watch
240-
// snapshot, then the test fails because the subsequent CURRENT below
241-
// is turned into a delete of docB.
242-
//.watchSnapshots(2000)
239+
.watchSnapshots(2000)
243240

244241
// Additionally CURRENT the query (should have no effect)
245242
.watchCurrents(docBQuery, 'resume-token-3000')
@@ -254,4 +251,72 @@ describeSpec('Limbo Documents:', [], () => {
254251
.expectLimboDocs()
255252
);
256253
});
254+
255+
// Same as above test, except docB no longer exists so we do not get a
256+
// documentUpdate for it during limbo resolution, so a delete should be
257+
// synthesized.
258+
specTest(
259+
'Limbo resolution handles snapshot before CURRENT [no document update]',
260+
[],
261+
() => {
262+
const fullQuery = Query.atPath(path('collection'));
263+
const limitQuery = Query.atPath(path('collection'))
264+
.addFilter(filter('include', '==', true))
265+
.withLimit(1);
266+
const docA = doc('collection/a', 1000, { key: 'a', include: true });
267+
const docB = doc('collection/b', 1000, { key: 'b', include: true });
268+
const docBQuery = Query.atPath(docB.key.path);
269+
return (
270+
spec()
271+
// No GC so we can keep the cache populated.
272+
.withGCEnabled(false)
273+
274+
// Full query to populate the cache with docA and docB
275+
.userListens(fullQuery)
276+
.watchAcksFull(fullQuery, 1000, docA, docB)
277+
.expectEvents(fullQuery, {
278+
added: [docA, docB]
279+
})
280+
.userUnlistens(fullQuery)
281+
282+
// Perform limit(1) query.
283+
.userListens(limitQuery)
284+
.expectEvents(limitQuery, {
285+
added: [docA],
286+
fromCache: true
287+
})
288+
.watchAcksFull(limitQuery, 2000, docA)
289+
.expectEvents(limitQuery, {
290+
fromCache: false
291+
})
292+
293+
// Edit docA so it no longer matches the query and we pull in docB
294+
// from cache.
295+
.userPatches('collection/a', { include: false })
296+
.expectEvents(limitQuery, {
297+
removed: [docA],
298+
added: [docB],
299+
fromCache: true
300+
})
301+
// docB is in limbo since we haven't gotten the watch update to pull
302+
// it in yet.
303+
.expectLimboDocs(docB.key)
304+
305+
// Suppose docB was actually deleted server-side and so we receive an
306+
// ack, a snapshot, CURRENT, and then another snapshot. This should
307+
// resolve the limbo resolution and docB should disappear.
308+
.watchAcks(docBQuery)
309+
.watchSnapshots(2000)
310+
.watchCurrents(docBQuery, 'resume-token-3000')
311+
.watchSnapshots(3000)
312+
.expectLimboDocs()
313+
.expectEvents(limitQuery, { removed: [docB], fromCache: false })
314+
315+
// Watch catches up to the local write to docA, and broadcasts its
316+
// removal.
317+
.watchSends({ removed: [limitQuery] }, docA)
318+
.watchSnapshots(4000)
319+
);
320+
}
321+
);
257322
});

0 commit comments

Comments
 (0)