-
Notifications
You must be signed in to change notification settings - Fork 208
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
feat: enable retry for addItem and add more debug info #14
Conversation
8c0a512
to
1658026
Compare
@@ -100,6 +100,17 @@ | |||
"globals": "^11.1.0", | |||
"invariant": "^2.2.0", | |||
"lodash": "^4.17.5" | |||
}, |
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.
Do we want package-lock.json
in this repository?
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, package.lock
is good for an application to make it repeatable. For example, we can use npm ci and npm audit fix
.
await sleep(interval); | ||
} else { | ||
// No more retries, timeout | ||
const msg = `Fail to ${task.description} after ${maxRetries * |
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.
nitpick: Fail to
-> Failed to
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.
I'm fine with either tense.
* @param maxRetries Maximum number of tries, default to 10 | ||
* @param interval Milliseconds to wait after each try, default to 100ms | ||
*/ | ||
async function retry<T>( |
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.
do we want tests for this function?
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.
We could add a unit test but not sure how to test it with redis.
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.
Added tests.
f2f1805
to
bf3c6e0
Compare
description: `update the shopping cart for '${userId}'`, | ||
}, | ||
10, | ||
100, |
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.
I find these positional arguments difficult to understand. What is 10
and 100
?
I am proposing to change retry
API to accept an options object instead of positional parameters.
return retry<ShoppingCart>(
{
run: () => this.shoppingCartRepository.addItem(userId, item),
description: `update the shopping cart for '${userId}'`,
},
{
maxRetries: 10,
retryInterval: 100,
});
@@ -42,6 +42,9 @@ export class ShoppingCartRepository extends DefaultKeyValueRepository< | |||
* @param userId User id | |||
* @param check A function that checks the current value and produces a new | |||
* value. It returns `null` to abort. | |||
* | |||
* @returns A promise of the updated ShoppingCart instance or `null` if the |
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.
I find this sentence ambiguous. If the transaction fails, is the function returning null
or a Promise that's resolved to null
?
Also: A transaction can fail for other reasons besides a race condition, for example the server can become unreachable or unresponsive. We are not returning null
but rejecting the promise in those cases!
Proposed wording:
A promise that's resolved with the updated ShoppingCart instance or with null
if the transaction failed because of a race condition.
src/utils/retry.ts
Outdated
export interface Task<T> { | ||
run(): Promise<T | null | undefined>; | ||
// tslint:disable-next-line:no-any | ||
isDone?(err: any, result: T | null | undefined): result is T; |
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.
@@ -78,6 +85,14 @@ export class ShoppingCartController { | |||
@param.path.string('userId') userId: string, | |||
@requestBody({description: 'shopping cart item'}) item: ShoppingCartItem, | |||
) { | |||
await this.shoppingCartRepository.addItem(userId, item); | |||
debug('Add item %j to shopping cart %s', item, userId); | |||
return retry<ShoppingCart>( |
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.
IMO, it should be the responsibility of a repository to deal with the need to retry this transaction. To me, it's an implementation detail specific to Redis storage. If we use a different backend that supports atomic partial updates, then retry
won't be necessary.
It feels very wrong to me that we have to change controller code when the backing database is changed. The repository should be the part of the application dealing with database complexities.
Please move retry
to ShoppingCartRepository
.
src/utils/retry.ts
Outdated
|
||
// tslint:disable-next-line:no-any | ||
function taskFinished<T>(err: any, result: T | null | undefined): result is T { | ||
return err == null && result != null; |
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.
Assuming we want to make retry
generic: I think it's not safe to assume that null
/undefined
is always an invalid result signaling a need to retry.
I think we should remove result != null
check and modify the shopping-cart code calling retry
to provide isDone
function too. (In which case my previous comment #14 (comment) should be ignored.)
Alternatively, it may be better & easier to combine these two function into one and change the return value into an object with two properties: done: boolean
and value: T
.
{done: true, value: /*result*/}
- the task finished successfully{done: false /* value can be omitted */}
- a retry is needed
One more important point: your current implementation retries regardless of whether the transaction was successfully executed with rollback result or whether there was an error which may prevent retries from succeeding (the commands are not valid, the server is not responding, etc.). That does not feel right to me, as it can retry a request that's known to fail again, unnecessarily putting more pressure on the backend system.
IMO, this retry
function should fail immediately when the task fails. Implementing a circuit-switcher behavior is out of scope of this work.
@@ -117,24 +117,26 @@ describe('ShoppingCartController', () => { | |||
await cartRepo.deleteAll(); | |||
} | |||
|
|||
function givenAnItem(item?: Partial<ShoppingCartItem>) { | |||
return new ShoppingCartItem( | |||
item || { |
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.
Data builders typically use Object.assign
, allowing users to leverage sensible defaults provided by the data builder and customize only the properties relevant to the test.
const defaultData = {
// sensible property values
};
return new ShoppingCartItem(
Object.assign(defaultData, item)
);
function givenShoppingCart() { | ||
return new ShoppingCart({ | ||
userId: 'user-0001', | ||
items: [ | ||
new ShoppingCartItem({ | ||
givenAnItem({ | ||
productId: 'iPhone XS Max', | ||
quantity: 1, | ||
price: 1200, |
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.
Just use givenAnItem()
, the specific property values are not important here.
src/utils/retry.ts
Outdated
* @param ms Number of milliseconds to wait | ||
*/ | ||
export function sleep(ms: number) { | ||
return promisify(setTimeout)(ms); |
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.
Please move promisify(setTimeout)
out of this function, so that we don't have to create a new function whenever sleep is called.
const setTimeoutAsync = promisify(setTimeout);
export function sleep(ms) {
return setTimeoutAsync(ms);
}
I think this can be further simplified as follows:
export const sleep = promisify(setTimeout);
e08b4de
to
44f163a
Compare
@bajtos Great feedback. PTAL. |
src/utils/retry.ts
Outdated
// tslint:disable-next-line:no-any | ||
err: any, | ||
result: T | null | undefined, | ||
): {done: boolean; value: T | null | undefined}; |
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.
IIUC, check
is both signaling whether the task is finished and converting the outcome of run
to the final value. That seems overly complicated to me, and at the same time not flexible enough, because check
has T
both on input and output, thus it cannot do that much.
In my original comment, I had the following interface in my mind:
export interface TaskFinished<T> {
done: true;
result: T;
}
export interface RetryNeeded {
done: false;
}
export interface Task<T> {
run(): Promise<TaskFinished<T> | RetryNeeded>;
description?: string;
}
On the second look, I see that your check
method is accepting also err
that may have been returned by run
. What benefits do you see in this design?
I would personally prefer to use regular async/await and try/catch flow for error handling, instead of having to use run/check semantics.
run() {
try {
// do some work
return {done: true, value: 'some data'};
} catch (err) {
// if the error means I should retry
return {done: false}
else throw err;
}
}
Thoughts?
44f163a
to
b82d847
Compare
@bajtos PTAL |
Signed-off-by: Raymond Feng <[email protected]>
b82d847
to
3ed03d9
Compare
|
||
export interface TaskStatus<T> { | ||
done: boolean; | ||
value?: T | null; |
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.
Nitpick: this type allows the developer to provide combinations of done/value that are not valid:
{done: true}
(Missing value. What should be the outcome of the operation?){done: false, value: 'some data'}
maxTries, | ||
); | ||
const status = await task.run(); | ||
if (status.done) return status.value!; |
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.
status.value!
Please don't! The typings are designed in such way that task.run()
can return a value that's undefined.
If you want to preserve TaskStatus
interface then you need to change retry
to return T | null
and push the responsibility of handling null/undefined result to callers of retry
. This is not a great UX to me.
I think a better solution is to follow my proposal outlined in #14 (comment), I believe it provides information to allow TypeScript understand that when status.done
is true, then status.value
must be defined.
export interface TaskFinished<T> {
done: true;
result: T;
}
export interface RetryNeeded {
done: false;
}
export interface Task<T> {
run(): Promise<TaskFinished<T> | RetryNeeded>;
description?: string;
}
const msg = `Failed to ${task.description} after ${maxTries * | ||
interval} ms`; | ||
debug('%s', msg); | ||
throw new HttpErrors.RequestTimeout(msg); |
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.
Nitpick: Since retry
is not used at REST layer, but inside a repository, I think we shouldn't be throwing an HttpError and use err.code
to provide a machine-readable flag that can be used to detect this particular error.
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.
The current implementation of retry
is good enough for the specific use case of our shopping example.
My last comments above would become important if we wanted to make retry
more generic and move it to loopback-next.
Follow up #13 per @bajtos comments:
retry
for adding items to a shopping cart