Skip to content

Add transaction retries #2250

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Oct 10, 2019
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 72 additions & 43 deletions packages/firestore/src/local/indexeddb_persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,15 @@ export class IndexedDbTransaction extends PersistenceTransaction {
}
}

// The different modes supported by `IndexedDbPersistence.runTransaction()`
type TransactionMode =
| 'readonly'
| 'readwrite'
| 'readwrite-primary'
| 'readonly-idempotent'
| 'readwrite-idempotent'
| 'readwrite-primary-idempotent';

/**
* An IndexedDB-backed instance of Persistence. Data is stored persistently
* across sessions.
Expand Down Expand Up @@ -321,6 +330,7 @@ export class IndexedDbPersistence implements Persistence {
.then(() => {
return this.simpleDb.runTransaction(
'readonly',
/* idempotent= */ false,
[DbTargetGlobal.store],
txn => {
return getHighestListenSequenceNumber(txn).next(
Expand All @@ -344,8 +354,11 @@ export class IndexedDbPersistence implements Persistence {
}

private startRemoteDocumentCache(): Promise<void> {
return this.simpleDb.runTransaction('readonly', ALL_STORES, txn =>
this.remoteDocumentCache.start(txn)
return this.simpleDb.runTransaction(
'readonly',
/* idempotent= */ false,
ALL_STORES,
txn => this.remoteDocumentCache.start(txn)
);
}

Expand Down Expand Up @@ -391,47 +404,52 @@ export class IndexedDbPersistence implements Persistence {
* primary lease.
*/
private updateClientMetadataAndTryBecomePrimary(): Promise<void> {
return this.simpleDb.runTransaction('readwrite', ALL_STORES, txn => {
const metadataStore = clientMetadataStore(txn);
return metadataStore
.put(
new DbClientMetadata(
this.clientId,
Date.now(),
this.networkEnabled,
this.inForeground
return this.simpleDb.runTransaction(
'readwrite',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is surprising to me. It seems like if SimpleDb implemented the various modes these implementations could remain unchanged.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed this to plumb through this setting via the mode strings. This sounded better in theory then it actually is, as it lead to a lot of string parsing and makes re-constructing the final IDBTransactionMode pretty ugly. Since this change is temporary and reduces the diff of this PR considerably it might be worth the trade off.

/* idempotent= */ false,
ALL_STORES,
txn => {
const metadataStore = clientMetadataStore(txn);
return metadataStore
.put(
new DbClientMetadata(
this.clientId,
Date.now(),
this.networkEnabled,
this.inForeground
)
)
)
.next(() => {
if (this.isPrimary) {
return this.verifyPrimaryLease(txn).next(success => {
if (!success) {
this.isPrimary = false;
this.queue.enqueueAndForget(() =>
this.primaryStateListener(false)
);
}
});
}
})
.next(() => this.canActAsPrimary(txn))
.next(canActAsPrimary => {
const wasPrimary = this.isPrimary;
this.isPrimary = canActAsPrimary;

if (wasPrimary !== this.isPrimary) {
this.queue.enqueueAndForget(() =>
this.primaryStateListener(this.isPrimary)
);
}
.next(() => {
if (this.isPrimary) {
return this.verifyPrimaryLease(txn).next(success => {
if (!success) {
this.isPrimary = false;
this.queue.enqueueAndForget(() =>
this.primaryStateListener(false)
);
}
});
}
})
.next(() => this.canActAsPrimary(txn))
.next(canActAsPrimary => {
const wasPrimary = this.isPrimary;
this.isPrimary = canActAsPrimary;

if (wasPrimary !== this.isPrimary) {
this.queue.enqueueAndForget(() =>
this.primaryStateListener(this.isPrimary)
);
}

if (wasPrimary && !this.isPrimary) {
return this.releasePrimaryLeaseIfHeld(txn);
} else if (this.isPrimary) {
return this.acquireOrExtendPrimaryLease(txn);
}
});
});
if (wasPrimary && !this.isPrimary) {
return this.releasePrimaryLeaseIfHeld(txn);
} else if (this.isPrimary) {
return this.acquireOrExtendPrimaryLease(txn);
}
});
}
);
}

private verifyPrimaryLease(
Expand Down Expand Up @@ -646,6 +664,7 @@ export class IndexedDbPersistence implements Persistence {
this.detachWindowUnloadHook();
await this.simpleDb.runTransaction(
'readwrite',
/* idempotent= */ false,
[DbPrimaryClient.store, DbClientMetadata.store],
txn => {
return this.releasePrimaryLeaseIfHeld(txn).next(() =>
Expand Down Expand Up @@ -678,6 +697,7 @@ export class IndexedDbPersistence implements Persistence {
getActiveClients(): Promise<ClientId[]> {
return this.simpleDb.runTransaction(
'readonly',
/* idempotent= */ false,
[DbClientMetadata.store],
txn => {
return clientMetadataStore(txn)
Expand Down Expand Up @@ -742,17 +762,26 @@ export class IndexedDbPersistence implements Persistence {

runTransaction<T>(
action: string,
mode: 'readonly' | 'readwrite' | 'readwrite-primary',
mode: TransactionMode,
transactionOperation: (
transaction: PersistenceTransaction
) => PersistencePromise<T>
): Promise<T> {
log.debug(LOG_TAG, 'Starting transaction:', action);

const idempotent =
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about

const idempotent = mode.endsWith('idempotent');
const readonly = mode.startsWith('readonly');

?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure. Remind me of this comment though when someone figures out how to use IndexedDb in IE11, which doesn't support these fancy String APIs :)

[
'readonly-idempotent',
'readwrite-idempotent',
'readwrite-primary-idempotent'
].indexOf(mode) !== -1;
const readonly = ['readonly', 'readonly-idempotent'].indexOf(mode) !== -1;

// Do all transactions as readwrite against all object stores, since we
// are the only reader/writer.
return this.simpleDb.runTransaction(
mode === 'readonly' ? 'readonly' : 'readwrite',
readonly ? 'readonly' : 'readwrite',
idempotent,
ALL_STORES,
simpleDbTxn => {
if (mode === 'readwrite-primary') {
Expand Down
88 changes: 66 additions & 22 deletions packages/firestore/src/local/simple_db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ import { PersistencePromise } from './persistence_promise';

const LOG_TAG = 'SimpleDb';

/**
* The maximum number of retry attempts for an IndexedDb transaction that fails
* with a DOMException.
*/
const TRANSACTION_RETRY_COUNT = 3;

export interface SimpleDbSchemaConverter {
createOrUpgrade(
db: IDBDatabase,
Expand Down Expand Up @@ -242,32 +248,58 @@ export class SimpleDb {
};
}

runTransaction<T>(
async runTransaction<T>(
mode: 'readonly' | 'readwrite',
idempotent: boolean,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type of this parameter is inconsistent with mode, which is also effectively a boolean. Could you make both of these numeric enums? That would avoid all the /* idempotent= */ comments.

However, see above: I think it's likely better to have idempotent mode variants here too.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am using the same mode Strings here now (minus the -primary).

objectStores: string[],
transactionFn: (transaction: SimpleDbTransaction) => PersistencePromise<T>
): Promise<T> {
const transaction = SimpleDbTransaction.open(this.db, mode, objectStores);
const transactionFnResult = transactionFn(transaction)
.catch(error => {
// Abort the transaction if there was an error.
transaction.abort(error);
// We cannot actually recover, and calling `abort()` will cause the transaction's
// completion promise to be rejected. This in turn means that we won't use
// `transactionFnResult` below. We return a rejection here so that we don't add the
// possibility of returning `void` to the type of `transactionFnResult`.
return PersistencePromise.reject<T>(error);
})
.toPromise();

// As noted above, errors are propagated by aborting the transaction. So
// we swallow any error here to avoid the browser logging it as unhandled.
transactionFnResult.catch(() => {});

// Wait for the transaction to complete (i.e. IndexedDb's onsuccess event to
// fire), but still return the original transactionFnResult back to the
// caller.
return transaction.completionPromise.then(() => transactionFnResult);
let attemptNumber = 0;

while (true) {
++attemptNumber;

const transaction = SimpleDbTransaction.open(this.db, mode, objectStores);
try {
const transactionFnResult = transactionFn(transaction)
.catch(error => {
// Abort the transaction if there was an error.
transaction.abort(error);
// We cannot actually recover, and calling `abort()` will cause the transaction's
// completion promise to be rejected. This in turn means that we won't use
// `transactionFnResult` below. We return a rejection here so that we don't add the
// possibility of returning `void` to the type of `transactionFnResult`.
return PersistencePromise.reject<T>(error);
})
.toPromise();

// As noted above, errors are propagated by aborting the transaction. So
// we swallow any error here to avoid the browser logging it as unhandled.
transactionFnResult.catch(() => {});

// Wait for the transaction to complete (i.e. IndexedDb's onsuccess event to
// fire), but still return the original transactionFnResult back to the
// caller.
await transaction.completionPromise;
return transactionFnResult;
} catch (e) {
// TODO: We could probably be smarter about this an not retry exceptions
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO(schmidt-sebastian)? or bug number?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

// that are likely unrecoverable (such as quota exceeded errors).
const retryable =
idempotent &&
isDomException(e) &&
attemptNumber < TRANSACTION_RETRY_COUNT;
debug(
'Transaction failed with error: %s. Retrying: %s.',
e.message,
retryable
);

if (!retryable) {
return Promise.reject(e);
}
}
}
}

close(): void {
Expand Down Expand Up @@ -755,3 +787,15 @@ function checkForAndReportiOSError(error: DOMException): Error {
}
return error;
}

/**
* Checks whether an error is a DOMException (e.g. as thrown by IndexedDb).
*
* Supports both browsers and Node with persistence.
*/
function isDomException(e: Error): boolean {
return (
(typeof DOMException !== 'undefined' && e instanceof DOMException) ||
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this true in our fake IndexedDb implementation too?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to elude to this in the JavaDoc comment, but moved the comment into the function. typeof DOMException !== 'undefined' is not true in node:persistence, but` e.constructor.name === 'DOMException' is.

e.constructor.name === 'DOMException'
);
}
11 changes: 8 additions & 3 deletions packages/firestore/test/unit/local/encoded_resource_path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,12 @@ function runTransaction<T>(
transaction: SimpleDbTransaction
) => PersistencePromise<T>
): Promise<T> {
return db.runTransaction<T>('readwrite', ['test'], txn => {
return fn(txn.store<string, boolean>('test'), txn);
});
return db.runTransaction<T>(
'readwrite',
/* idempotent= */ false,
['test'],
txn => {
return fn(txn.store<string, boolean>('test'), txn);
}
);
}
Loading