Skip to content

Commit 8814a3c

Browse files
Add transaction retries (#2250)
1 parent 8c2bbc3 commit 8814a3c

File tree

3 files changed

+178
-25
lines changed

3 files changed

+178
-25
lines changed

packages/firestore/src/local/indexeddb_persistence.ts

+22-2
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,15 @@ export class IndexedDbTransaction extends PersistenceTransaction {
121121
}
122122
}
123123

124+
// The different modes supported by `IndexedDbPersistence.runTransaction()`
125+
type IndexedDbTransactionMode =
126+
| 'readonly'
127+
| 'readwrite'
128+
| 'readwrite-primary'
129+
| 'readonly-idempotent'
130+
| 'readwrite-idempotent'
131+
| 'readwrite-primary-idempotent';
132+
124133
/**
125134
* An IndexedDB-backed instance of Persistence. Data is stored persistently
126135
* across sessions.
@@ -742,17 +751,28 @@ export class IndexedDbPersistence implements Persistence {
742751

743752
runTransaction<T>(
744753
action: string,
745-
mode: 'readonly' | 'readwrite' | 'readwrite-primary',
754+
mode: IndexedDbTransactionMode,
746755
transactionOperation: (
747756
transaction: PersistenceTransaction
748757
) => PersistencePromise<T>
749758
): Promise<T> {
750759
log.debug(LOG_TAG, 'Starting transaction:', action);
751760

761+
// TODO(schmidt-sebastian): Simplify once all transactions are idempotent.
762+
const idempotent = mode.endsWith('idempotent');
763+
const readonly = mode.startsWith('readonly');
764+
const simpleDbMode = readonly
765+
? idempotent
766+
? 'readonly-idempotent'
767+
: 'readonly'
768+
: idempotent
769+
? 'readwrite-idempotent'
770+
: 'readwrite';
771+
752772
// Do all transactions as readwrite against all object stores, since we
753773
// are the only reader/writer.
754774
return this.simpleDb.runTransaction(
755-
mode === 'readonly' ? 'readonly' : 'readwrite',
775+
simpleDbMode,
756776
ALL_STORES,
757777
simpleDbTxn => {
758778
if (mode === 'readwrite-primary') {

packages/firestore/src/local/simple_db.ts

+78-23
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,19 @@ import { PersistencePromise } from './persistence_promise';
2525

2626
const LOG_TAG = 'SimpleDb';
2727

28+
/**
29+
* The maximum number of retry attempts for an IndexedDb transaction that fails
30+
* with a DOMException.
31+
*/
32+
const TRANSACTION_RETRY_COUNT = 3;
33+
34+
// The different modes supported by `SimpleDb.runTransaction()`
35+
type SimpleDbTransactionMode =
36+
| 'readonly'
37+
| 'readwrite'
38+
| 'readonly-idempotent'
39+
| 'readwrite-idempotent';
40+
2841
export interface SimpleDbSchemaConverter {
2942
createOrUpgrade(
3043
db: IDBDatabase,
@@ -242,32 +255,64 @@ export class SimpleDb {
242255
};
243256
}
244257

245-
runTransaction<T>(
246-
mode: 'readonly' | 'readwrite',
258+
async runTransaction<T>(
259+
mode: SimpleDbTransactionMode,
247260
objectStores: string[],
248261
transactionFn: (transaction: SimpleDbTransaction) => PersistencePromise<T>
249262
): Promise<T> {
250-
const transaction = SimpleDbTransaction.open(this.db, mode, objectStores);
251-
const transactionFnResult = transactionFn(transaction)
252-
.catch(error => {
253-
// Abort the transaction if there was an error.
254-
transaction.abort(error);
255-
// We cannot actually recover, and calling `abort()` will cause the transaction's
256-
// completion promise to be rejected. This in turn means that we won't use
257-
// `transactionFnResult` below. We return a rejection here so that we don't add the
258-
// possibility of returning `void` to the type of `transactionFnResult`.
259-
return PersistencePromise.reject<T>(error);
260-
})
261-
.toPromise();
262-
263-
// As noted above, errors are propagated by aborting the transaction. So
264-
// we swallow any error here to avoid the browser logging it as unhandled.
265-
transactionFnResult.catch(() => {});
266-
267-
// Wait for the transaction to complete (i.e. IndexedDb's onsuccess event to
268-
// fire), but still return the original transactionFnResult back to the
269-
// caller.
270-
return transaction.completionPromise.then(() => transactionFnResult);
263+
const readonly = mode.startsWith('readonly');
264+
const idempotent = mode.endsWith('idempotent');
265+
let attemptNumber = 0;
266+
267+
while (true) {
268+
++attemptNumber;
269+
270+
const transaction = SimpleDbTransaction.open(
271+
this.db,
272+
readonly ? 'readonly' : 'readwrite',
273+
objectStores
274+
);
275+
try {
276+
const transactionFnResult = transactionFn(transaction)
277+
.catch(error => {
278+
// Abort the transaction if there was an error.
279+
transaction.abort(error);
280+
// We cannot actually recover, and calling `abort()` will cause the transaction's
281+
// completion promise to be rejected. This in turn means that we won't use
282+
// `transactionFnResult` below. We return a rejection here so that we don't add the
283+
// possibility of returning `void` to the type of `transactionFnResult`.
284+
return PersistencePromise.reject<T>(error);
285+
})
286+
.toPromise();
287+
288+
// As noted above, errors are propagated by aborting the transaction. So
289+
// we swallow any error here to avoid the browser logging it as unhandled.
290+
transactionFnResult.catch(() => {});
291+
292+
// Wait for the transaction to complete (i.e. IndexedDb's onsuccess event to
293+
// fire), but still return the original transactionFnResult back to the
294+
// caller.
295+
await transaction.completionPromise;
296+
return transactionFnResult;
297+
} catch (e) {
298+
// TODO(schmidt-sebastian): We could probably be smarter about this and
299+
// not retry exceptions that are likely unrecoverable (such as quota
300+
// exceeded errors).
301+
const retryable =
302+
idempotent &&
303+
isDomException(e) &&
304+
attemptNumber < TRANSACTION_RETRY_COUNT;
305+
debug(
306+
'Transaction failed with error: %s. Retrying: %s.',
307+
e.message,
308+
retryable
309+
);
310+
311+
if (!retryable) {
312+
return Promise.reject(e);
313+
}
314+
}
315+
}
271316
}
272317

273318
close(): void {
@@ -755,3 +800,13 @@ function checkForAndReportiOSError(error: DOMException): Error {
755800
}
756801
return error;
757802
}
803+
804+
/** Checks whether an error is a DOMException (e.g. as thrown by IndexedDb). */
805+
function isDomException(error: Error): boolean {
806+
// DOMException is not a global type in Node with persistence, and hence we
807+
// check the constructor name if the type in unknown.
808+
return (
809+
(typeof DOMException !== 'undefined' && error instanceof DOMException) ||
810+
error.constructor.name === 'DOMException'
811+
);
812+
}

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

+78
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,84 @@ describe('SimpleDb', () => {
500500
});
501501
});
502502

503+
it('retries transactions marked as idempotent', async () => {
504+
let attemptCount = 0;
505+
506+
const result = await db.runTransaction(
507+
'readwrite-idempotent',
508+
['users'],
509+
txn => {
510+
++attemptCount;
511+
if (attemptCount === 1) {
512+
const store = txn.store<string[], typeof dummyUser>('users');
513+
return store
514+
.add(dummyUser)
515+
.next(() => {
516+
return store.add(dummyUser); // Fails with a unique key violation
517+
})
518+
.next(() => 'Aborted');
519+
} else {
520+
return PersistencePromise.resolve('success');
521+
}
522+
}
523+
);
524+
525+
expect(result).to.equal('success');
526+
expect(attemptCount).to.equal(2);
527+
});
528+
529+
it('retries transactions only three times', async () => {
530+
let attemptCount = 0;
531+
532+
await expect(
533+
db.runTransaction('readwrite-idempotent', ['users'], txn => {
534+
++attemptCount;
535+
const store = txn.store<string[], typeof dummyUser>('users');
536+
return store
537+
.add(dummyUser)
538+
.next(() => {
539+
return store.add(dummyUser); // Fails with a unique key violation
540+
})
541+
.next(() => 'Aborted');
542+
})
543+
).to.eventually.be.rejected;
544+
545+
expect(attemptCount).to.equal(3);
546+
});
547+
548+
it('does not retry explicitly aborted transactions', async () => {
549+
let attemptCount = 0;
550+
551+
await expect(
552+
db.runTransaction('readwrite-idempotent', ['users'], txn => {
553+
++attemptCount;
554+
txn.abort();
555+
return PersistencePromise.reject(new Error('Aborted'));
556+
})
557+
).to.eventually.be.rejected;
558+
559+
expect(attemptCount).to.equal(1);
560+
});
561+
562+
it('does not retry non-idempotent transactions', async () => {
563+
let attemptCount = 0;
564+
565+
await expect(
566+
db.runTransaction('readwrite', ['users'], txn => {
567+
++attemptCount;
568+
const store = txn.store<string[], typeof dummyUser>('users');
569+
return store
570+
.add(dummyUser)
571+
.next(() => {
572+
return store.add(dummyUser); // Fails with a unique key violation
573+
})
574+
.next(() => 'Aborted');
575+
})
576+
).to.eventually.be.rejected;
577+
578+
expect(attemptCount).to.equal(1);
579+
});
580+
503581
// A little perf test for convenient benchmarking
504582
// eslint-disable-next-line no-restricted-properties
505583
it.skip('Perf', () => {

0 commit comments

Comments
 (0)