Skip to content

Commit 8afb5e5

Browse files
Add transaction retries
1 parent 48642ad commit 8afb5e5

File tree

3 files changed

+166
-24
lines changed

3 files changed

+166
-24
lines changed

packages/firestore/src/local/indexeddb_persistence.ts

+20-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 TransactionMode =
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.
@@ -753,17 +762,26 @@ export class IndexedDbPersistence implements Persistence {
753762

754763
runTransaction<T>(
755764
action: string,
756-
mode: 'readonly' | 'readwrite' | 'readwrite-primary',
765+
mode: TransactionMode,
757766
transactionOperation: (
758767
transaction: PersistenceTransaction
759768
) => PersistencePromise<T>
760769
): Promise<T> {
761770
log.debug(LOG_TAG, 'Starting transaction:', action);
762771

772+
const idempotent =
773+
[
774+
'readonly-idempotent',
775+
'readwrite-idempotent',
776+
'readwrite-primary-idempotent'
777+
].indexOf(mode) !== -1;
778+
const readonly = ['readonly', 'readonly-idempotent'].indexOf(mode) !== -1;
779+
763780
// Do all transactions as readwrite against all object stores, since we
764781
// are the only reader/writer.
765782
return this.simpleDb.runTransaction(
766-
mode === 'readonly' ? 'readonly' : 'readwrite',
783+
readonly ? 'readonly' : 'readwrite',
784+
idempotent,
767785
ALL_STORES,
768786
simpleDbTxn => {
769787
if (mode === 'readwrite-primary') {

packages/firestore/src/local/simple_db.ts

+66-22
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ 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+
2834
export interface SimpleDbSchemaConverter {
2935
createOrUpgrade(
3036
db: IDBDatabase,
@@ -242,32 +248,58 @@ export class SimpleDb {
242248
};
243249
}
244250

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

273305
close(): void {
@@ -755,3 +787,15 @@ function checkForAndReportiOSError(error: DOMException): Error {
755787
}
756788
return error;
757789
}
790+
791+
/**
792+
* Checks whether an error is a DOMException (e.g. as thrown by IndexedDb).
793+
*
794+
* Supports both browsers and Node with persistence.
795+
*/
796+
function isDomException(e: Error): boolean {
797+
return (
798+
(typeof DOMException !== 'undefined' && e instanceof DOMException) ||
799+
e.constructor.name === 'DOMException'
800+
);
801+
}

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

+80
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,86 @@ describe('SimpleDb', () => {
515515
);
516516
});
517517

518+
it('retries idempotent transactions', async () => {
519+
let attemptCount = 0;
520+
521+
const result = await db.runTransaction(
522+
'readwrite',
523+
/* idempotent= */ true,
524+
['users'],
525+
txn => {
526+
++attemptCount;
527+
if (attemptCount === 1) {
528+
const store = txn.store<string[], typeof dummyUser>('users');
529+
return store
530+
.add(dummyUser)
531+
.next(() => {
532+
return store.add(dummyUser);
533+
})
534+
.next(() => 'Uncaught unique key violation');
535+
} else {
536+
console.log('retry');
537+
return PersistencePromise.resolve('success');
538+
}
539+
}
540+
);
541+
542+
expect(result).to.equal('success');
543+
expect(attemptCount).to.equal(2);
544+
});
545+
546+
it('retries transaction only three times', async () => {
547+
let attemptCount = 0;
548+
549+
await expect(
550+
db.runTransaction('readwrite', /* idempotent= */ true, ['users'], txn => {
551+
++attemptCount;
552+
const store = txn.store<string[], typeof dummyUser>('users');
553+
return store
554+
.add(dummyUser)
555+
.next(() => {
556+
return store.add(dummyUser);
557+
})
558+
.next(() => 'Uncaught unique key violation');
559+
})
560+
).to.eventually.be.rejected;
561+
562+
expect(attemptCount).to.equal(3);
563+
});
564+
565+
it('does not retry explicitly aborted transactions', async () => {
566+
let attemptCount = 0;
567+
568+
await expect(
569+
db.runTransaction('readonly', /* idempotent= */ true, ['users'], txn => {
570+
++attemptCount;
571+
txn.abort();
572+
return PersistencePromise.reject(new Error('Aborted'));
573+
})
574+
).to.eventually.be.rejected;
575+
576+
expect(attemptCount).to.equal(1);
577+
});
578+
579+
it('does not retry non-idempotent transactions', async () => {
580+
let attemptCount = 0;
581+
582+
await expect(
583+
db.runTransaction('readonly', /* idempotent= */ false, ['users'], txn => {
584+
++attemptCount;
585+
const store = txn.store<string[], typeof dummyUser>('users');
586+
return store
587+
.add(dummyUser)
588+
.next(() => {
589+
return store.add(dummyUser);
590+
})
591+
.next(() => 'Uncaught unique key violation');
592+
})
593+
).to.eventually.be.rejectedWith('Uncaught unique key violation');
594+
595+
expect(attemptCount).to.equal(1);
596+
});
597+
518598
// A little perf test for convenient benchmarking
519599
// eslint-disable-next-line no-restricted-properties
520600
it.skip('Perf', () => {

0 commit comments

Comments
 (0)