Skip to content

Commit a36b51b

Browse files
Don't crash if write cannot be persisted (#2938)
1 parent 0601283 commit a36b51b

File tree

7 files changed

+172
-87
lines changed

7 files changed

+172
-87
lines changed

packages/firestore/CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
# Unreleased
2+
- [fixed] Firestore now rejects write operations if they cannot be persisted
3+
in IndexedDB. Previously, these errors crashed the client.
24
- [fixed] Fixed a source of IndexedDB-related crashes for tabs that receive
35
multi-tab notifications while the file system is locked.
46

packages/firestore/src/core/sync_engine.ts

+24-3
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@
1616
*/
1717

1818
import { User } from '../auth/user';
19-
import { ignoreIfPrimaryLeaseLoss, LocalStore } from '../local/local_store';
19+
import {
20+
ignoreIfPrimaryLeaseLoss,
21+
LocalStore,
22+
LocalWriteResult
23+
} from '../local/local_store';
2024
import { LocalViewChanges } from '../local/local_view_changes';
2125
import { ReferenceSet } from '../local/reference_set';
2226
import { TargetData, TargetPurpose } from '../local/target_data';
@@ -34,7 +38,7 @@ import { RemoteStore } from '../remote/remote_store';
3438
import { RemoteSyncer } from '../remote/remote_syncer';
3539
import { debugAssert, fail, hardAssert } from '../util/assert';
3640
import { Code, FirestoreError } from '../util/error';
37-
import { logDebug } from '../util/log';
41+
import { logDebug, logError } from '../util/log';
3842
import { primitiveComparator } from '../util/misc';
3943
import { ObjectMap } from '../util/obj_map';
4044
import { Deferred } from '../util/promise';
@@ -371,7 +375,24 @@ export class SyncEngine implements RemoteSyncer, SharedClientStateSyncer {
371375
*/
372376
async write(batch: Mutation[], userCallback: Deferred<void>): Promise<void> {
373377
this.assertSubscribed('write()');
374-
const result = await this.localStore.localWrite(batch);
378+
379+
let result: LocalWriteResult;
380+
try {
381+
result = await this.localStore.localWrite(batch);
382+
} catch (e) {
383+
if (e.name === 'IndexedDbTransactionError') {
384+
// If we can't persist the mutation, we reject the user callback and
385+
// don't send the mutation. The user can then retry the write.
386+
logError(LOG_TAG, 'Dropping write that cannot be persisted: ' + e);
387+
userCallback.reject(
388+
new FirestoreError(Code.UNAVAILABLE, 'Failed to persist write: ' + e)
389+
);
390+
return;
391+
} else {
392+
throw e;
393+
}
394+
}
395+
375396
this.sharedClientState.addPendingMutation(result.batchId);
376397
this.addMutationCallback(result.batchId, userCallback);
377398
await this.emitNewSnapsAndNotifyLocalStore(result.changes);

packages/firestore/test/integration/bootstrap.ts

+6-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
22
* @license
3-
* Copyright 2017 Google Inc.
3+
* Copyright 2017 Google LLC
44
*
55
* Licensed under the Apache License, Version 2.0 (the "License");
66
* you may not use this file except in compliance with the License.
@@ -26,7 +26,11 @@ import '../../index';
2626

2727
// 'context()' definition requires additional dependency on webpack-env package.
2828
// eslint-disable-next-line @typescript-eslint/no-explicit-any
29-
const testsContext = (require as any).context('.', true, /^((?!node).)*\.test$/);
29+
const testsContext = (require as any).context(
30+
'.',
31+
true,
32+
/^((?!node).)*\.test$/
33+
);
3034
const browserTests = testsContext
3135
.keys()
3236
.filter((file: string) => !file.match(/([\/.])node([\/.])/));

packages/firestore/test/unit/bootstrap.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@ import '../../src/platform_browser/browser_init';
2626

2727
// 'context()' definition requires additional dependency on webpack-env package.
2828
// eslint-disable-next-line @typescript-eslint/no-explicit-any
29-
const testsContext = (require as any).context('.', true, /^((?!node).)*\.test$/);
29+
const testsContext = (require as any).context(
30+
'.',
31+
true,
32+
/^((?!node).)*\.test$/
33+
);
3034
const browserTests = testsContext
3135
.keys()
3236
.filter((file: string) => !file.match(/([\/.])node([\/.])/));

packages/firestore/test/unit/remote/serializer.helper.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ const protoJsReader = testUserDataReader(/* useProto3Json= */ false);
9696
*/
9797
export function serializerTest(
9898
protobufJsVerifier: (jsonValue: api.Value) => void = () => {}
99-
) : void {
99+
): void {
100100
describe('Serializer', () => {
101101
const partition = new DatabaseId('p', 'd');
102102
const s = new JsonProtoSerializer(partition, { useProto3Json: false });

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

+131-79
Original file line numberDiff line numberDiff line change
@@ -16,98 +16,150 @@
1616
*/
1717

1818
import { describeSpec, specTest } from './describe_spec';
19-
import { client } from './spec_builder';
19+
import { client, spec } from './spec_builder';
2020
import { TimerId } from '../../../src/util/async_queue';
2121
import { Query } from '../../../src/core/query';
22-
import { path } from '../../util/helpers';
22+
import { doc, path } from '../../util/helpers';
2323

24-
describeSpec(
25-
'Persistence Recovery',
26-
['durable-persistence', 'no-ios', 'no-android'],
27-
() => {
28-
specTest(
29-
'Write is acknowledged by primary client (with recovery)',
30-
['multi-client'],
31-
() => {
32-
return (
33-
client(0)
34-
.expectPrimaryState(true)
35-
.client(1)
36-
.expectPrimaryState(false)
37-
.userSets('collection/a', { v: 1 })
38-
.failDatabase()
39-
.client(0)
40-
.writeAcks('collection/a', 1, { expectUserCallback: false })
41-
.client(1)
42-
// Client 1 has received the WebStorage notification that the write
43-
// has been acknowledged, but failed to process the change. Hence,
44-
// we did not get a user callback. We schedule the first retry and
45-
// make sure that it also does not get processed until
46-
// `recoverDatabase` is called.
47-
.runTimer(TimerId.AsyncQueueRetry)
48-
.recoverDatabase()
49-
.runTimer(TimerId.AsyncQueueRetry)
50-
.expectUserCallbacks({
51-
acknowledged: ['collection/a']
52-
})
53-
);
54-
}
55-
);
24+
describeSpec('Persistence Recovery', ['no-ios', 'no-android'], () => {
25+
specTest(
26+
'Write is acknowledged by primary client (with recovery)',
27+
['multi-client'],
28+
() => {
29+
return (
30+
client(0)
31+
.expectPrimaryState(true)
32+
.client(1)
33+
.expectPrimaryState(false)
34+
.userSets('collection/a', { v: 1 })
35+
.failDatabase()
36+
.client(0)
37+
.writeAcks('collection/a', 1, { expectUserCallback: false })
38+
.client(1)
39+
// Client 1 has received the WebStorage notification that the write
40+
// has been acknowledged, but failed to process the change. Hence,
41+
// we did not get a user callback. We schedule the first retry and
42+
// make sure that it also does not get processed until
43+
// `recoverDatabase` is called.
44+
.runTimer(TimerId.AsyncQueueRetry)
45+
.recoverDatabase()
46+
.runTimer(TimerId.AsyncQueueRetry)
47+
.expectUserCallbacks({
48+
acknowledged: ['collection/a']
49+
})
50+
);
51+
}
52+
);
53+
54+
specTest(
55+
'Query raises events in secondary client (with recovery)',
56+
['multi-client'],
57+
() => {
58+
const query = Query.atPath(path('collection'));
5659

57-
specTest(
58-
'Query raises events in secondary client (with recovery)',
59-
['multi-client'],
60-
() => {
61-
const query = Query.atPath(path('collection'));
60+
return client(0)
61+
.expectPrimaryState(true)
62+
.client(1)
63+
.expectPrimaryState(false)
64+
.userListens(query)
65+
.failDatabase()
66+
.client(0)
67+
.expectListen(query)
68+
.watchAcksFull(query, 1000)
69+
.client(1)
70+
.recoverDatabase()
71+
.runTimer(TimerId.AsyncQueueRetry)
72+
.expectEvents(query, {});
73+
}
74+
);
6275

63-
return client(0)
76+
specTest(
77+
'Query is listened to by primary (with recovery)',
78+
['multi-client'],
79+
() => {
80+
const query = Query.atPath(path('collection'));
81+
82+
return (
83+
client(0)
6484
.expectPrimaryState(true)
85+
.failDatabase()
6586
.client(1)
66-
.expectPrimaryState(false)
6787
.userListens(query)
68-
.failDatabase()
6988
.client(0)
89+
// The primary client 0 receives a WebStorage notification about the
90+
// new query, but it cannot load the target from IndexedDB. The
91+
// query will only be listened to once we recover the database.
92+
.recoverDatabase()
93+
.runTimer(TimerId.AsyncQueueRetry)
7094
.expectListen(query)
71-
.watchAcksFull(query, 1000)
95+
.failDatabase()
7296
.client(1)
97+
.userUnlistens(query)
98+
.client(0)
99+
// The primary client 0 receives a notification that the query can
100+
// be released, but it can only process the change after we recover
101+
// the database.
102+
.expectActiveTargets({ query })
73103
.recoverDatabase()
74104
.runTimer(TimerId.AsyncQueueRetry)
75-
.expectEvents(query, {});
76-
}
77-
);
105+
.expectActiveTargets()
106+
);
107+
}
108+
);
78109

79-
specTest(
80-
'Query is listened to by primary (with recovery)',
81-
['multi-client'],
82-
() => {
83-
const query = Query.atPath(path('collection'));
110+
specTest('Recovers when write cannot be persisted', [], () => {
111+
return spec()
112+
.userSets('collection/key1', { foo: 'a' })
113+
.expectNumOutstandingWrites(1)
114+
.failDatabase()
115+
.userSets('collection/key2', { bar: 'b' })
116+
.expectUserCallbacks({ rejected: ['collection/key2'] })
117+
.recoverDatabase()
118+
.expectNumOutstandingWrites(1)
119+
.userSets('collection/key3', { baz: 'c' })
120+
.expectNumOutstandingWrites(2)
121+
.writeAcks('collection/key1', 1)
122+
.writeAcks('collection/key3', 2)
123+
.expectNumOutstandingWrites(0);
124+
});
84125

85-
return (
86-
client(0)
87-
.expectPrimaryState(true)
88-
.failDatabase()
89-
.client(1)
90-
.userListens(query)
91-
.client(0)
92-
// The primary client 0 receives a WebStorage notification about the
93-
// new query, but it cannot load the target from IndexedDB. The
94-
// query will only be listened to once we recover the database.
95-
.recoverDatabase()
96-
.runTimer(TimerId.AsyncQueueRetry)
97-
.expectListen(query)
98-
.failDatabase()
99-
.client(1)
100-
.userUnlistens(query)
101-
.client(0)
102-
// The primary client 0 receives a notification that the query can
103-
// be released, but it can only process the change after we recover
104-
// the database.
105-
.expectActiveTargets({ query })
106-
.recoverDatabase()
107-
.runTimer(TimerId.AsyncQueueRetry)
108-
.expectActiveTargets()
109-
);
110-
}
126+
specTest('Does not surface non-persisted writes', [], () => {
127+
const query = Query.atPath(path('collection'));
128+
const doc1Local = doc(
129+
'collection/key1',
130+
0,
131+
{ foo: 'a' },
132+
{ hasLocalMutations: true }
133+
);
134+
const doc1 = doc('collection/key1', 1, { foo: 'a' });
135+
const doc3Local = doc(
136+
'collection/key3',
137+
0,
138+
{ foo: 'c' },
139+
{ hasLocalMutations: true }
111140
);
112-
}
113-
);
141+
const doc3 = doc('collection/key3', 2, { foo: 'c' });
142+
return spec()
143+
.userListens(query)
144+
.userSets('collection/key1', { foo: 'a' })
145+
.expectEvents(query, {
146+
added: [doc1Local],
147+
fromCache: true,
148+
hasPendingWrites: true
149+
})
150+
.failDatabase()
151+
.userSets('collection/key2', { foo: 'b' })
152+
.expectUserCallbacks({ rejected: ['collection/key2'] })
153+
.recoverDatabase()
154+
.userSets('collection/key3', { foo: 'c' })
155+
.expectEvents(query, {
156+
added: [doc3Local],
157+
fromCache: true,
158+
hasPendingWrites: true
159+
})
160+
.writeAcks('collection/key1', 1)
161+
.writeAcks('collection/key3', 2)
162+
.watchAcksFull(query, 2, doc1, doc3)
163+
.expectEvents(query, { metadata: [doc1, doc3] });
164+
});
165+
});

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

+3-1
Original file line numberDiff line numberDiff line change
@@ -687,7 +687,9 @@ abstract class TestRunner {
687687
() => this.rejectedDocs.push(...documentKeys)
688688
);
689689

690-
this.sharedWrites.push(mutations);
690+
if (!this.persistence.injectFailures) {
691+
this.sharedWrites.push(mutations);
692+
}
691693

692694
return this.queue.enqueue(() => {
693695
return this.syncEngine.write(mutations, syncEngineCallback);

0 commit comments

Comments
 (0)