Skip to content

Commit ef5fb1b

Browse files
Address potential IndexedDB failure in synchronizeQueryViewsAndRaiseSnapshots (#3113)
1 parent 230cd48 commit ef5fb1b

File tree

5 files changed

+97
-13
lines changed

5 files changed

+97
-13
lines changed

packages/firestore/src/core/sync_engine.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1110,14 +1110,10 @@ export class MultiTabSyncEngine extends SyncEngine
11101110
const queries = this.queriesByTarget.get(targetId);
11111111

11121112
if (queries && queries.length !== 0) {
1113-
// For queries that have a local View, we need to update their state
1114-
// in LocalStore (as the resume token and the snapshot version
1113+
// For queries that have a local View, we fetch their current state
1114+
// from LocalStore (as the resume token and the snapshot version
11151115
// might have changed) and reconcile their views with the persisted
11161116
// state (the list of syncedDocuments may have gotten out of sync).
1117-
await this.localStore.releaseTarget(
1118-
targetId,
1119-
/*keepPersistedTargetData=*/ true
1120-
);
11211117
targetData = await this.localStore.allocateTarget(
11221118
queries[0].toTarget()
11231119
);
@@ -1136,7 +1132,7 @@ export class MultiTabSyncEngine extends SyncEngine
11361132
} else {
11371133
debugAssert(
11381134
transitionToPrimary,
1139-
'A secondary tab should never have an active target without an active query.'
1135+
'A secondary tab should never have an active view without an active target.'
11401136
);
11411137
// For queries that never executed on this client, we need to
11421138
// allocate the target in LocalStore and initialize a new View.

packages/firestore/src/local/indexeddb_persistence.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -404,7 +404,7 @@ export class IndexedDbPersistence implements Persistence {
404404
return this.verifyPrimaryLease(txn).next(success => {
405405
if (!success) {
406406
this.isPrimary = false;
407-
this.queue.enqueueAndForget(() =>
407+
this.queue.enqueueRetryable(() =>
408408
this.primaryStateListener(false)
409409
);
410410
}
@@ -444,7 +444,7 @@ export class IndexedDbPersistence implements Persistence {
444444
})
445445
.then(isPrimary => {
446446
if (this.isPrimary !== isPrimary) {
447-
this.queue.enqueueAndForget(() =>
447+
this.queue.enqueueRetryable(() =>
448448
this.primaryStateListener(isPrimary)
449449
);
450450
}
@@ -805,7 +805,7 @@ export class IndexedDbPersistence implements Persistence {
805805
`Failed to obtain primary lease for action '${action}'.`
806806
);
807807
this.isPrimary = false;
808-
this.queue.enqueueAndForget(() =>
808+
this.queue.enqueueRetryable(() =>
809809
this.primaryStateListener(false)
810810
);
811811
throw new FirestoreError(

packages/firestore/src/local/local_store.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -831,7 +831,17 @@ export class LocalStore {
831831
});
832832
})
833833
.then(targetData => {
834-
if (this.targetDataByTarget.get(targetData.targetId) === null) {
834+
// If Multi-Tab is enabled, the existing target data may be newer than
835+
// the in-memory data
836+
const cachedTargetData = this.targetDataByTarget.get(
837+
targetData.targetId
838+
);
839+
if (
840+
cachedTargetData === null ||
841+
targetData.snapshotVersion.compareTo(
842+
cachedTargetData.snapshotVersion
843+
) > 0
844+
) {
835845
this.targetDataByTarget = this.targetDataByTarget.insert(
836846
targetData.targetId,
837847
targetData

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
import { Query } from '../../../src/core/query';
1919
import { Code } from '../../../src/util/error';
20-
import { deletedDoc, doc, filter, path, orderBy } from '../../util/helpers';
20+
import { deletedDoc, doc, filter, orderBy, path } from '../../util/helpers';
2121

2222
import { TimerId } from '../../../src/util/async_queue';
2323
import { describeSpec, specTest } from './describe_spec';

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

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ describeSpec('Persistence Recovery', ['no-ios', 'no-android'], () => {
8888
);
8989

9090
specTest(
91-
'Query raises events in secondary client (with recovery)',
91+
'Query raises events in secondary client (with recovery)',
9292
['multi-client'],
9393
() => {
9494
const query = Query.atPath(path('collection'));
@@ -144,6 +144,84 @@ describeSpec('Persistence Recovery', ['no-ios', 'no-android'], () => {
144144
}
145145
);
146146

147+
specTest(
148+
'Query with active view recovers after primary tab failover (with recovery)',
149+
['multi-client'],
150+
() => {
151+
const query = Query.atPath(path('collection'));
152+
const docA = doc('collection/a', 1000, { key: 'a' });
153+
const docB = doc('collection/b', 2000, { key: 'b' });
154+
155+
return (
156+
client(0)
157+
.expectPrimaryState(true)
158+
.client(1)
159+
// Register a query in the secondary client
160+
.userListens(query)
161+
.client(0)
162+
.expectListen(query)
163+
.watchAcksFull(query, 1000, docA)
164+
// Shutdown the primary client to release its lease
165+
.shutdown()
166+
.client(1)
167+
.expectEvents(query, { added: [docA] })
168+
// Run the lease refresh to attempt taking over the primary lease. The
169+
// first lease refresh fails with a simulated transaction failure.
170+
.failDatabaseTransactions('Allocate target')
171+
.runTimer(TimerId.ClientMetadataRefresh)
172+
.expectPrimaryState(false)
173+
.recoverDatabase()
174+
.runTimer(TimerId.AsyncQueueRetry)
175+
.expectPrimaryState(true)
176+
.expectListen(query, 'resume-token-1000')
177+
.watchAcksFull(query, 2000, docB)
178+
.expectEvents(query, { added: [docB] })
179+
);
180+
}
181+
);
182+
183+
specTest(
184+
'Query without active view recovers after primary tab failover (with recovery)',
185+
['multi-client'],
186+
() => {
187+
const query = Query.atPath(path('collection'));
188+
const docA = doc('collection/a', 1000, { key: 'a' });
189+
const docB = doc('collection/b', 2000, { key: 'b' });
190+
191+
return (
192+
client(0)
193+
.expectPrimaryState(true)
194+
// Initialize a second client that doesn't have any active targets
195+
.client(1)
196+
.client(2)
197+
// Register a query in the third client
198+
.userListens(query)
199+
.client(0)
200+
.expectListen(query)
201+
.watchAcksFull(query, 1000, docA)
202+
.client(2)
203+
.expectEvents(query, { added: [docA] })
204+
.client(0)
205+
// Shutdown the primary client to release its lease
206+
.shutdown()
207+
.client(1)
208+
// Run the lease refresh in the second client, which does not yet have
209+
// an active view for the third client's query. The lease refresh fails
210+
// at first, but then recovers and initializes the view.
211+
.failDatabaseTransactions('Allocate target')
212+
.runTimer(TimerId.ClientMetadataRefresh)
213+
.expectPrimaryState(false)
214+
.recoverDatabase()
215+
.runTimer(TimerId.AsyncQueueRetry)
216+
.expectPrimaryState(true)
217+
.expectListen(query, 'resume-token-1000')
218+
.watchAcksFull(query, 2000, docB)
219+
.client(2)
220+
.expectEvents(query, { added: [docB] })
221+
);
222+
}
223+
);
224+
147225
specTest(
148226
'Ignores intermittent lease refresh failures (with recovery)',
149227
['multi-client'],

0 commit comments

Comments
 (0)