Skip to content

Commit 919bcbd

Browse files
Don't persist documents that we already have (#2099)
1 parent 1dbf3bc commit 919bcbd

File tree

4 files changed

+94
-103
lines changed

4 files changed

+94
-103
lines changed

packages/firestore/src/local/local_store.ts

Lines changed: 30 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -449,37 +449,23 @@ export class LocalStore {
449449
*/
450450
applyRemoteEvent(remoteEvent: RemoteEvent): Promise<MaybeDocumentMap> {
451451
const documentBuffer = this.remoteDocuments.newChangeBuffer();
452+
const snapshotVersion = remoteEvent.snapshotVersion;
452453
return this.persistence.runTransaction(
453454
'Apply remote event',
454455
'readwrite-primary',
455456
txn => {
456457
const promises = [] as Array<PersistencePromise<void>>;
457-
let authoritativeUpdates = documentKeySet();
458458
objUtils.forEachNumber(
459459
remoteEvent.targetChanges,
460460
(targetId: TargetId, change: TargetChange) => {
461-
// Do not ref/unref unassigned targetIds - it may lead to leaks.
462-
let queryData = this.queryDataByTarget[targetId];
463-
if (!queryData) {
461+
const oldQueryData = this.queryDataByTarget[targetId];
462+
if (!oldQueryData) {
464463
return;
465464
}
466465

467-
// When a global snapshot contains updates (either add or modify) we
468-
// can completely trust these updates as authoritative and blindly
469-
// apply them to our cache (as a defensive measure to promote
470-
// self-healing in the unfortunate case that our cache is ever somehow
471-
// corrupted / out-of-sync).
472-
//
473-
// If the document is only updated while removing it from a target
474-
// then watch isn't obligated to send the absolute latest version: it
475-
// can send the first version that caused the document not to match.
476-
change.addedDocuments.forEach(key => {
477-
authoritativeUpdates = authoritativeUpdates.add(key);
478-
});
479-
change.modifiedDocuments.forEach(key => {
480-
authoritativeUpdates = authoritativeUpdates.add(key);
481-
});
482-
466+
// Only update the remote keys if the query is still active. This
467+
// ensures that we can persist the updated query data along with
468+
// the updated assignment.
483469
promises.push(
484470
this.queryCache
485471
.removeMatchingKeys(txn, change.removedDocuments, targetId)
@@ -492,25 +478,27 @@ export class LocalStore {
492478
})
493479
);
494480

495-
// Update the resume token if the change includes one. Don't clear
496-
// any preexisting value.
497481
const resumeToken = change.resumeToken;
482+
// Update the resume token if the change includes one.
498483
if (resumeToken.length > 0) {
499-
const oldQueryData = queryData;
500-
queryData = queryData.copy({
484+
const newQueryData = oldQueryData.copy({
501485
resumeToken,
502-
snapshotVersion: remoteEvent.snapshotVersion
486+
snapshotVersion
503487
});
504-
this.queryDataByTarget[targetId] = queryData;
488+
this.queryDataByTarget[targetId] = newQueryData;
505489

490+
// Update the query data if there are target changes (or if
491+
// sufficient time has passed since the last update).
506492
if (
507493
LocalStore.shouldPersistQueryData(
508494
oldQueryData,
509-
queryData,
495+
newQueryData,
510496
change
511497
)
512498
) {
513-
promises.push(this.queryCache.updateQueryData(txn, queryData));
499+
promises.push(
500+
this.queryCache.updateQueryData(txn, newQueryData)
501+
);
514502
}
515503
}
516504
}
@@ -528,19 +516,12 @@ export class LocalStore {
528516
documentBuffer.getEntries(txn, updatedKeys).next(existingDocs => {
529517
remoteEvent.documentUpdates.forEach((key, doc) => {
530518
const existingDoc = existingDocs.get(key);
531-
// If a document update isn't authoritative, make sure we don't
532-
// apply an old document version to the remote cache. We make an
533-
// exception for SnapshotVersion.MIN which can happen for
534-
// manufactured events (e.g. in the case of a limbo document
535-
// resolution failing).
536519
if (
537520
existingDoc == null ||
538-
(authoritativeUpdates.has(doc.key) &&
539-
!existingDoc.hasPendingWrites) ||
540-
doc.version.compareTo(existingDoc.version) >= 0
521+
doc.version.compareTo(existingDoc.version) > 0 ||
522+
(doc.version.compareTo(existingDoc.version) === 0 &&
523+
existingDoc.hasPendingWrites)
541524
) {
542-
// If a document update isn't authoritative, make sure we don't apply an old document
543-
// version to the remote cache.
544525
documentBuffer.addEntry(doc);
545526
changedDocs = changedDocs.insert(key, doc);
546527
} else if (
@@ -580,22 +561,21 @@ export class LocalStore {
580561
// can synthesize remote events when we get permission denied errors while
581562
// trying to resolve the state of a locally cached document that is in
582563
// limbo.
583-
const remoteVersion = remoteEvent.snapshotVersion;
584-
if (!remoteVersion.isEqual(SnapshotVersion.MIN)) {
564+
if (!snapshotVersion.isEqual(SnapshotVersion.MIN)) {
585565
const updateRemoteVersion = this.queryCache
586566
.getLastRemoteSnapshotVersion(txn)
587-
.next(lastRemoteVersion => {
567+
.next(lastRemoteSnapshotVersion => {
588568
assert(
589-
remoteVersion.compareTo(lastRemoteVersion) >= 0,
569+
snapshotVersion.compareTo(lastRemoteSnapshotVersion) >= 0,
590570
'Watch stream reverted to previous snapshot?? ' +
591-
remoteVersion +
571+
snapshotVersion +
592572
' < ' +
593-
lastRemoteVersion
573+
lastRemoteSnapshotVersion
594574
);
595575
return this.queryCache.setTargetsMetadata(
596576
txn,
597577
txn.currentSequenceNumber,
598-
remoteVersion
578+
snapshotVersion
599579
);
600580
});
601581
promises.push(updateRemoteVersion);
@@ -629,12 +609,12 @@ export class LocalStore {
629609
newQueryData: QueryData,
630610
change: TargetChange
631611
): boolean {
632-
// Avoid clearing any existing value
633-
if (newQueryData.resumeToken.length === 0) {
634-
return false;
635-
}
612+
assert(
613+
newQueryData.resumeToken.length > 0,
614+
'Attempted to persist query data with no resume token'
615+
);
636616

637-
// Any resume token is interesting if there isn't one already.
617+
// Always persist query data if we don't already have a resume token.
638618
if (oldQueryData.resumeToken.length === 0) {
639619
return true;
640620
}

packages/firestore/test/unit/local/local_store.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1261,4 +1261,32 @@ function genericLocalStoreTests(
12611261
.toReturnHighestUnacknowledgeBatchId(BATCHID_UNKNOWN)
12621262
.finish();
12631263
});
1264+
1265+
it('only persists updates for focuments when version changes', () => {
1266+
const query = Query.atPath(path('foo'));
1267+
return (
1268+
expectLocalStore()
1269+
.afterAllocatingQuery(query)
1270+
.toReturnTargetId(2)
1271+
.afterRemoteEvent(
1272+
docAddedRemoteEvent(doc('foo/bar', 1, { val: 'old' }), [2])
1273+
)
1274+
.toReturnChanged(doc('foo/bar', 1, { val: 'old' }))
1275+
.toContain(doc('foo/bar', 1, { val: 'old' }))
1276+
.afterRemoteEvent(
1277+
docAddedRemoteEvent(
1278+
[
1279+
doc('foo/bar', 1, { val: 'new' }),
1280+
doc('foo/baz', 2, { val: 'new' })
1281+
],
1282+
[2]
1283+
)
1284+
)
1285+
.toReturnChanged(doc('foo/baz', 2, { val: 'new' }))
1286+
// The update to foo/bar is ignored.
1287+
.toContain(doc('foo/bar', 1, { val: 'old' }))
1288+
.toContain(doc('foo/baz', 2, { val: 'new' }))
1289+
.finish()
1290+
);
1291+
});
12641292
}

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

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -413,40 +413,6 @@ describeSpec('Listens:', [], () => {
413413
);
414414
});
415415

416-
specTest('Deleted documents in cache are fixed', [], () => {
417-
const allQuery = Query.atPath(path('collection'));
418-
const setupQuery = allQuery.addFilter(filter('key', '==', 'a'));
419-
420-
const docAv1 = doc('collection/a', 1000, { key: 'a' });
421-
const docDeleted = deletedDoc('collection/a', 2000);
422-
423-
return (
424-
spec()
425-
// Presuppose an initial state where the remote document cache has a
426-
// broken synthesized delete at a timestamp later than the true version
427-
// of the document. This requires both adding and later removing the
428-
// document in order to force the watch change aggregator to propagate
429-
// the deletion.
430-
.withGCEnabled(false)
431-
.userListens(setupQuery)
432-
.watchAcksFull(setupQuery, 1000, docAv1)
433-
.expectEvents(setupQuery, { added: [docAv1], fromCache: false })
434-
.watchSends({ removed: [setupQuery] }, docDeleted)
435-
.watchSnapshots(2000, [setupQuery], 'resume-token-2000')
436-
.watchSnapshots(2000)
437-
.expectEvents(setupQuery, { removed: [docAv1], fromCache: false })
438-
.userUnlistens(setupQuery)
439-
.watchRemoves(setupQuery)
440-
441-
// Now when the client listens expect the cached NoDocument to be
442-
// discarded because the global snapshot version exceeds what came
443-
// before.
444-
.userListens(allQuery)
445-
.watchAcksFull(allQuery, 3000, docAv1)
446-
.expectEvents(allQuery, { added: [docAv1], fromCache: false })
447-
);
448-
});
449-
450416
specTest('Listens are reestablished after network disconnect', [], () => {
451417
const expectRequestCount = (requestCounts: {
452418
[type: string]: number;

packages/firestore/test/util/helpers.ts

Lines changed: 36 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -284,33 +284,50 @@ export function queryData(
284284
}
285285

286286
export function docAddedRemoteEvent(
287-
doc: MaybeDocument,
287+
docOrDocs: MaybeDocument | MaybeDocument[],
288288
updatedInTargets?: TargetId[],
289289
removedFromTargets?: TargetId[],
290-
limboTargets?: TargetId[]
290+
activeTargets?: TargetId[]
291291
): RemoteEvent {
292-
assert(
293-
!(doc instanceof Document) || !doc.hasLocalMutations,
294-
"Docs from remote updates shouldn't have local changes."
295-
);
296-
const docChange = new DocumentWatchChange(
297-
updatedInTargets || [],
298-
removedFromTargets || [],
299-
doc.key,
300-
doc
301-
);
292+
const docs = Array.isArray(docOrDocs) ? docOrDocs : [docOrDocs];
293+
assert(docs.length !== 0, 'Cannot pass empty docs array');
294+
295+
const allTargets = activeTargets
296+
? activeTargets
297+
: (updatedInTargets || []).concat(removedFromTargets || []);
298+
302299
const aggregator = new WatchChangeAggregator({
303300
getRemoteKeysForTarget: () => documentKeySet(),
304301
getQueryDataForTarget: targetId => {
305-
const purpose =
306-
limboTargets && limboTargets.indexOf(targetId) !== -1
307-
? QueryPurpose.LimboResolution
308-
: QueryPurpose.Listen;
309-
return queryData(targetId, purpose, doc.key.toString());
302+
if (allTargets.indexOf(targetId) !== -1) {
303+
const collectionPath = docs[0].key.path.popLast();
304+
return queryData(
305+
targetId,
306+
QueryPurpose.Listen,
307+
collectionPath.toString()
308+
);
309+
} else {
310+
return null;
311+
}
310312
}
311313
});
312-
aggregator.handleDocumentChange(docChange);
313-
return aggregator.createRemoteEvent(doc.version);
314+
315+
for (const doc of docs) {
316+
assert(
317+
!(doc instanceof Document) || !doc.hasLocalMutations,
318+
"Docs from remote updates shouldn't have local changes."
319+
);
320+
const docChange = new DocumentWatchChange(
321+
updatedInTargets || [],
322+
removedFromTargets || [],
323+
doc.key,
324+
doc
325+
);
326+
aggregator.handleDocumentChange(docChange);
327+
}
328+
329+
const version = docs[0].version;
330+
return aggregator.createRemoteEvent(version);
314331
}
315332

316333
export function docUpdateRemoteEvent(

0 commit comments

Comments
 (0)