Skip to content
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

fix: manually retry ABORTED reads in transactions #883

Merged
merged 5 commits into from
Jan 15, 2020
Merged
Show file tree
Hide file tree
Changes from all 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
24 changes: 22 additions & 2 deletions dev/src/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
* limitations under the License.
*/

import {GoogleError, Status} from 'google-gax';

import * as proto from '../protos/firestore_v1_proto_api';

import {DocumentSnapshot, Precondition} from './document';
Expand Down Expand Up @@ -434,8 +436,15 @@ export class Transaction {
'Rolling back transaction after callback error:',
err
);

await this.rollback();
return Promise.reject(err); // User callback failed

if (isRetryableTransactionError(err)) {
lastError = err;
continue; // Retry full transaction
} else {
return Promise.reject(err); // Callback failed w/ non-retryable error
}
}

try {
Expand All @@ -450,7 +459,7 @@ export class Transaction {
logger(
'Firestore.runTransaction',
this._requestTag,
'Exhausted transaction retries, returning error: %s',
'Transaction not eligible for retry, returning error: %s',
lastError
);
return Promise.reject(lastError);
Expand Down Expand Up @@ -552,3 +561,14 @@ function validateReadOptions(
}
}
}

function isRetryableTransactionError(error: Error): boolean {
if (error instanceof GoogleError || 'code' in error) {

Choose a reason for hiding this comment

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

Haven't seen this elsewhere: Is 'code' in error the standard way to check if the 'code' field exists?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, that is JavaScript functionality (see https://masteringjs.io/tutorials/fundamentals/hasownproperty for a comparison against Object.hasOwnProperty). I am doing it here because I am afraid that some of our dependencies might throw a GoogleError that is not actually a GoogleError by providing just a code and a message field.

// In transactions, the backend returns code ABORTED for reads that fail
// with contention. These errors should be retried for both GoogleError
// and GoogleError-alike errors (in case the prototype hierarchy gets
Copy link
Contributor

Choose a reason for hiding this comment

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

Could this check for error.name === 'GoogleError' instead?

When implementing gRPC servers, it's not unthinkable that an ABORTED error is thrown from inside the transaction, with the expectation that it's returned to the client.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This should accept any object that has a "code" property. In the environment you are describing, will this property be stripped?

Copy link
Contributor

Choose a reason for hiding this comment

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

Let me rephrase.

There are many ways of wrongly implementing a transaction function, to do with side-effects, or forgetting to use the transaction itself to perform queries.

This new ABORTED handling adds more ways to get it wrong.

There is an assumption that all GoogleError instances came from transaction methods. That may not be the case if I use another GCP SDK within my function. I know, I shouldn't be, but somebody still might.

It also won't be the case if I call a Firestore method on a DocumentReference which then throws an ABORTED error.

These assumptions are then generalized by testing if the thrown error has a .code = 10 property. I have a ServiceError object that is thrown with a gRPC response code. If for some reason I throw that error inside a transaction function, my transaction may be retried even if it succeeded. And again, sure, I suppose I shouldn't be throwing that error, but somebody still might.

A safer approach may be to register errors, thrown as part of the actual transaction, in a WeakMap. Then as they propagate through user code, when you catch them again you can check whether they're genuine transaction errors, and then check their code.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

A couple things to note:

  • A read outside of a transaction should never result in Code ABORTED. This error should only happen during a transactional read.
  • If a user throws an error with code: 10, we will retry the transaction. In general, transaction functions should be written so they can be re-run, and re-running them more often than needed should not cause any harm.

I understand that we control the errors that we are catching here. We could tighten this check to make sure that the error is indeed a GoogleError, but for now I decided to err on the side of caution and also retry GoogleError-alike errors. This allows us to catch errors with code properties that were created by our dependencies before we converted everything to use the proper GoogleError type.

// stripped somewhere).
return error.code === Status.ABORTED;
}
return false;
}
71 changes: 58 additions & 13 deletions dev/test/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ interface TransactionStep {
function commit(
transaction?: Uint8Array | string,
writes?: api.IWrite[],
err?: Error
error?: Error
): TransactionStep {
const proto: api.ICommitRequest = {
database: DATABASE_ROOT,
Expand Down Expand Up @@ -99,14 +99,14 @@ function commit(
return {
type: 'commit',
request: proto,
error: err,
error,
response,
};
}

function rollback(
transaction?: Uint8Array | string,
err?: Error
error?: Error

Choose a reason for hiding this comment

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

optional: change naming for commit() as well so they're all in sync (link)

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. Good catch.

): TransactionStep {
const proto = {
database: DATABASE_ROOT,
Expand All @@ -116,15 +116,15 @@ function rollback(
return {
type: 'rollback',
request: proto,
error: err,
error,
response: {},
};
}

function begin(
transaction?: Uint8Array | string,
prevTransaction?: Uint8Array | string,
err?: Error
error?: Error
): TransactionStep {
const proto: api.IBeginTransactionRequest = {database: DATABASE_ROOT};

Expand All @@ -143,12 +143,15 @@ function begin(
return {
type: 'begin',
request: proto,
error: err,
error,
response,
};
}

function getDocument(transaction?: Uint8Array | string): TransactionStep {
function getDocument(
transaction?: Uint8Array | string,
error?: Error
): TransactionStep {
const request = {
database: DATABASE_ROOT,
documents: [DOCUMENT_NAME],
Expand All @@ -172,6 +175,7 @@ function getDocument(transaction?: Uint8Array | string): TransactionStep {
return {
type: 'getDocument',
request,
error,
stream,
};
}
Expand Down Expand Up @@ -307,7 +311,11 @@ function runTransaction<T>(
const request = expectedRequests.shift()!;
expect(request.type).to.equal('getDocument');
expect(actual).to.deep.eq(request.request);
return request.stream!;
if (request.error) {
throw request.error;
} else {
return request.stream!;
}
},
runQuery: actual => {
const request = expectedRequests.shift()!;
Expand Down Expand Up @@ -396,7 +404,7 @@ describe('failed transactions', () => {

it('requires a promise', () => {
return expect(
runTransaction((() => {}) as InvalidApiUsage, begin(), rollback('foo'))
runTransaction((() => {}) as InvalidApiUsage, begin(), rollback())
).to.eventually.be.rejectedWith(
'You must return a Promise in your transaction()-callback.'
);
Expand All @@ -416,14 +424,51 @@ describe('failed transactions', () => {
});
});

it("doesn't retry on callback failure", () => {
it('retries GRPC exceptions with code ABORTED in callback', () => {
const retryableError = new GoogleError('Aborted');
retryableError.code = Status.ABORTED;

return runTransaction(
async (transaction, docRef) => {
await transaction.get(docRef);
return 'success';
},
begin('foo1'),
getDocument('foo1', retryableError),
rollback('foo1'),
begin('foo2', 'foo1'),
getDocument('foo2'),
commit('foo2')
).then(res => {
expect(res).to.equal('success');
});
});

it("doesn't retry GRPC exceptions with code FAILED_PRECONDITION in callback", () => {
const nonRetryableError = new GoogleError('Failed Precondition');
nonRetryableError.code = Status.FAILED_PRECONDITION;

return expect(
runTransaction(
async (transaction, docRef) => {
await transaction.get(docRef);
return 'failure';
},
begin('foo'),

Choose a reason for hiding this comment

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

If 'foo' is default, do we need it here as well, or can it also be removed?

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 left it as is here since getDocument uses the second argument. I prefer this over only specifying the transaction ID once or using undefined in its place.

getDocument('foo', nonRetryableError),
rollback('foo')
)
).to.eventually.be.rejectedWith('Failed Precondition');
});

it("doesn't retry custom user exceptions in callback", () => {
return expect(
runTransaction(
() => {
return Promise.reject('request exception');
},
begin(),
rollback('foo')
rollback()

Choose a reason for hiding this comment

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

should we make it consistent throughout elsewhere

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed

)
).to.eventually.be.rejectedWith('request exception');
});
Expand All @@ -442,8 +487,8 @@ describe('failed transactions', () => {
commit('foo2', [], serverError),
begin('foo3', 'foo2'),
commit('foo3')
).then(red => {
expect(red).to.equal('success');
).then(res => {
expect(res).to.equal('success');
});
});

Expand Down