-
Notifications
You must be signed in to change notification settings - Fork 148
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
Changes from all commits
e833a0d
2c66aba
5e930d5
43bc22e
06c482e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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'; | ||
|
@@ -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 { | ||
|
@@ -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); | ||
|
@@ -552,3 +561,14 @@ function validateReadOptions( | |
} | ||
} | ||
} | ||
|
||
function isRetryableTransactionError(error: Error): boolean { | ||
if (error instanceof GoogleError || 'code' in error) { | ||
// 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could this check for 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There is an assumption that all It also won't be the case if I call a Firestore method on a These assumptions are then generalized by testing if the thrown error has a A safer approach may be to register errors, thrown as part of the actual transaction, in a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A couple things to note:
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; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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, | ||
|
@@ -99,14 +99,14 @@ function commit( | |
return { | ||
type: 'commit', | ||
request: proto, | ||
error: err, | ||
error, | ||
response, | ||
}; | ||
} | ||
|
||
function rollback( | ||
transaction?: Uint8Array | string, | ||
err?: Error | ||
error?: Error | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. optional: change naming for There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done. Good catch. |
||
): TransactionStep { | ||
const proto = { | ||
database: DATABASE_ROOT, | ||
|
@@ -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}; | ||
|
||
|
@@ -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], | ||
|
@@ -172,6 +175,7 @@ function getDocument(transaction?: Uint8Array | string): TransactionStep { | |
return { | ||
type: 'getDocument', | ||
request, | ||
error, | ||
stream, | ||
}; | ||
} | ||
|
@@ -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()!; | ||
|
@@ -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.' | ||
); | ||
|
@@ -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'), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I left it as is here since |
||
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() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should we make it consistent throughout elsewhere There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed |
||
) | ||
).to.eventually.be.rejectedWith('request exception'); | ||
}); | ||
|
@@ -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'); | ||
}); | ||
}); | ||
|
||
|
There was a problem hiding this comment.
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?There was a problem hiding this comment.
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.