diff --git a/package-lock.json b/package-lock.json index 197468945..9e735c3dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -100,6 +100,17 @@ "globals": "^11.1.0", "invariant": "^2.2.0", "lodash": "^4.17.5" + }, + "dependencies": { + "debug": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.5.tgz", + "integrity": "sha512-D61LaDQPQkxJ5AUM2mbSJRbPkNs/TmdmOeLAi1hgDkpDfIfetSrjmWhccwtuResSwMbACjx/xXQofvM9CE/aeg==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + } } }, "@babel/types": { @@ -343,6 +354,16 @@ "@types/glob": "^5.0.35", "debug": "^3.1.0", "glob": "^7.1.2" + }, + "dependencies": { + "debug": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.5.tgz", + "integrity": "sha512-D61LaDQPQkxJ5AUM2mbSJRbPkNs/TmdmOeLAi1hgDkpDfIfetSrjmWhccwtuResSwMbACjx/xXQofvM9CE/aeg==", + "requires": { + "ms": "^2.1.1" + } + } } }, "@loopback/build": { @@ -379,6 +400,15 @@ "shebang-command": "^1.2.0", "which": "^1.2.9" } + }, + "debug": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.5.tgz", + "integrity": "sha512-D61LaDQPQkxJ5AUM2mbSJRbPkNs/TmdmOeLAi1hgDkpDfIfetSrjmWhccwtuResSwMbACjx/xXQofvM9CE/aeg==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } } } }, @@ -391,6 +421,16 @@ "@loopback/metadata": "^0.9.8", "debug": "^3.1.0", "uuid": "^3.2.1" + }, + "dependencies": { + "debug": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.5.tgz", + "integrity": "sha512-D61LaDQPQkxJ5AUM2mbSJRbPkNs/TmdmOeLAi1hgDkpDfIfetSrjmWhccwtuResSwMbACjx/xXQofvM9CE/aeg==", + "requires": { + "ms": "^2.1.1" + } + } } }, "@loopback/core": { @@ -427,6 +467,16 @@ "debug": "^3.1.0", "lodash": "^4.17.5", "reflect-metadata": "^0.1.10" + }, + "dependencies": { + "debug": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.5.tgz", + "integrity": "sha512-D61LaDQPQkxJ5AUM2mbSJRbPkNs/TmdmOeLAi1hgDkpDfIfetSrjmWhccwtuResSwMbACjx/xXQofvM9CE/aeg==", + "requires": { + "ms": "^2.1.1" + } + } } }, "@loopback/openapi-v3": { @@ -440,6 +490,16 @@ "@loopback/repository-json-schema": "^0.10.12", "debug": "^3.1.0", "lodash": "^4.17.5" + }, + "dependencies": { + "debug": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.5.tgz", + "integrity": "sha512-D61LaDQPQkxJ5AUM2mbSJRbPkNs/TmdmOeLAi1hgDkpDfIfetSrjmWhccwtuResSwMbACjx/xXQofvM9CE/aeg==", + "requires": { + "ms": "^2.1.1" + } + } } }, "@loopback/openapi-v3-types": { @@ -505,6 +565,16 @@ "qs": "^6.5.2", "strong-error-handler": "^3.2.0", "validator": "^10.4.0" + }, + "dependencies": { + "debug": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.5.tgz", + "integrity": "sha512-D61LaDQPQkxJ5AUM2mbSJRbPkNs/TmdmOeLAi1hgDkpDfIfetSrjmWhccwtuResSwMbACjx/xXQofvM9CE/aeg==", + "requires": { + "ms": "^2.1.1" + } + } } }, "@loopback/service-proxy": { @@ -1658,9 +1728,9 @@ } }, "debug": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.5.tgz", - "integrity": "sha512-D61LaDQPQkxJ5AUM2mbSJRbPkNs/TmdmOeLAi1hgDkpDfIfetSrjmWhccwtuResSwMbACjx/xXQofvM9CE/aeg==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.0.1.tgz", + "integrity": "sha512-K23FHJ/Mt404FSlp6gSZCevIbTMLX0j3fmHhUEhQ3Wq0FMODW3+cUSoLdy1Gx4polAf4t/lphhmHH35BB8cLYw==", "requires": { "ms": "^2.1.1" } @@ -3145,6 +3215,16 @@ "msgpack5": "^4.2.0", "strong-globalize": "^4.1.1", "uuid": "^3.0.1" + }, + "dependencies": { + "debug": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.5.tgz", + "integrity": "sha512-D61LaDQPQkxJ5AUM2mbSJRbPkNs/TmdmOeLAi1hgDkpDfIfetSrjmWhccwtuResSwMbACjx/xXQofvM9CE/aeg==", + "requires": { + "ms": "^2.1.1" + } + } } }, "loopback-connector-kv-redis": { @@ -3156,6 +3236,16 @@ "ioredis": "^3.2.2", "loopback-connector": "^4.0.0", "strong-globalize": "^4.1.0" + }, + "dependencies": { + "debug": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.5.tgz", + "integrity": "sha512-D61LaDQPQkxJ5AUM2mbSJRbPkNs/TmdmOeLAi1hgDkpDfIfetSrjmWhccwtuResSwMbACjx/xXQofvM9CE/aeg==", + "requires": { + "ms": "^2.1.1" + } + } } }, "loopback-connector-mongodb": { @@ -3169,6 +3259,16 @@ "loopback-connector": "^4.5.0", "mongodb": "^3.1.4", "strong-globalize": "^4.1.1" + }, + "dependencies": { + "debug": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.5.tgz", + "integrity": "sha512-D61LaDQPQkxJ5AUM2mbSJRbPkNs/TmdmOeLAi1hgDkpDfIfetSrjmWhccwtuResSwMbACjx/xXQofvM9CE/aeg==", + "requires": { + "ms": "^2.1.1" + } + } } }, "loopback-datasource-juggler": { @@ -3189,6 +3289,16 @@ "strong-globalize": "^4.1.1", "traverse": "^0.6.6", "uuid": "^3.0.1" + }, + "dependencies": { + "debug": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.5.tgz", + "integrity": "sha512-D61LaDQPQkxJ5AUM2mbSJRbPkNs/TmdmOeLAi1hgDkpDfIfetSrjmWhccwtuResSwMbACjx/xXQofvM9CE/aeg==", + "requires": { + "ms": "^2.1.1" + } + } } }, "loose-envify": { @@ -6805,6 +6915,17 @@ "strong-task-emitter": "^0.0.8", "typedoc": "^0.12.0", "underscore.string": "^3.3.4" + }, + "dependencies": { + "debug": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.5.tgz", + "integrity": "sha512-D61LaDQPQkxJ5AUM2mbSJRbPkNs/TmdmOeLAi1hgDkpDfIfetSrjmWhccwtuResSwMbACjx/xXQofvM9CE/aeg==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + } } }, "strong-error-handler": { @@ -6819,6 +6940,16 @@ "http-status": "^1.1.2", "js2xmlparser": "^3.0.0", "strong-globalize": "^4.1.0" + }, + "dependencies": { + "debug": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.5.tgz", + "integrity": "sha512-D61LaDQPQkxJ5AUM2mbSJRbPkNs/TmdmOeLAi1hgDkpDfIfetSrjmWhccwtuResSwMbACjx/xXQofvM9CE/aeg==", + "requires": { + "ms": "^2.1.1" + } + } } }, "strong-globalize": { @@ -6834,6 +6965,16 @@ "mkdirp": "^0.5.1", "os-locale": "^2.0.0", "yamljs": "^0.3.0" + }, + "dependencies": { + "debug": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.5.tgz", + "integrity": "sha512-D61LaDQPQkxJ5AUM2mbSJRbPkNs/TmdmOeLAi1hgDkpDfIfetSrjmWhccwtuResSwMbACjx/xXQofvM9CE/aeg==", + "requires": { + "ms": "^2.1.1" + } + } } }, "strong-task-emitter": { @@ -6843,6 +6984,17 @@ "dev": true, "requires": { "debug": "^3.1.0" + }, + "dependencies": { + "debug": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.5.tgz", + "integrity": "sha512-D61LaDQPQkxJ5AUM2mbSJRbPkNs/TmdmOeLAi1hgDkpDfIfetSrjmWhccwtuResSwMbACjx/xXQofvM9CE/aeg==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + } } }, "superagent": { @@ -6861,6 +7013,17 @@ "mime": "^1.4.1", "qs": "^6.5.1", "readable-stream": "^2.3.5" + }, + "dependencies": { + "debug": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.5.tgz", + "integrity": "sha512-D61LaDQPQkxJ5AUM2mbSJRbPkNs/TmdmOeLAi1hgDkpDfIfetSrjmWhccwtuResSwMbACjx/xXQofvM9CE/aeg==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + } } }, "supertest": { diff --git a/package.json b/package.json index 965f1241b..3aa272bd5 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "@loopback/repository": "^0.16.5", "@loopback/rest": "^0.22.2", "bcryptjs": "^2.4.3", + "debug": "^4.0.1", "isemail": "^3.1.3", "loopback-connector-kv-redis": "^3.0.0", "loopback-connector-mongodb": "^3.7.1" @@ -71,6 +72,7 @@ "@loopback/build": "^0.7.3", "@loopback/testlab": "^0.12.2", "@types/bcryptjs": "^2.4.1", + "@types/debug": "0.0.30", "@types/mocha": "^5.0.0", "@types/node": "^10.9.4", "commitizen": "^2.10.1", diff --git a/src/controllers/shopping-cart.controller.ts b/src/controllers/shopping-cart.controller.ts index 36063e085..cc1778ae7 100644 --- a/src/controllers/shopping-cart.controller.ts +++ b/src/controllers/shopping-cart.controller.ts @@ -15,6 +15,8 @@ import { import {repository} from '@loopback/repository'; import {ShoppingCartRepository} from '../repositories'; import {ShoppingCart, ShoppingCartItem} from '../models'; +import * as debugFactory from 'debug'; +const debug = debugFactory('loopback:example:shopping'); /** * Controller for shopping cart @@ -35,6 +37,7 @@ export class ShoppingCartController { @param.path.string('userId') userId: string, @requestBody({description: 'shopping cart'}) cart: ShoppingCart, ) { + debug('Create shopping cart %s: %j', userId, cart); if (userId !== cart.userId) { throw new HttpErrors.BadRequest( `User id does not match: ${userId} !== ${cart.userId}`, @@ -49,7 +52,9 @@ export class ShoppingCartController { */ @get('/shoppingCarts/{userId}') async get(@param.path.string('userId') userId: string) { + debug('Get shopping cart %s', userId); const cart = await this.shoppingCartRepository.get(userId); + debug('Shopping cart %s: %j', userId, cart); if (cart == null) { throw new HttpErrors.NotFound( `Shopping cart not found for user: ${userId}`, @@ -65,6 +70,7 @@ export class ShoppingCartController { */ @del('/shoppingCarts/{userId}') async remove(@param.path.string('userId') userId: string) { + debug('Remove shopping cart %s', userId); await this.shoppingCartRepository.delete(userId); } @@ -78,6 +84,64 @@ 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( + { + run: () => this.shoppingCartRepository.addItem(userId, item), + description: `update the shopping cart for '${userId}'`, + }, + 10, + 100, + ); } } + +/** + * Retry a task for number of times with the given interval in ms + * @param task Task object {run, description} + * @param maxRetries Maximum number of tries, default to 10 + * @param interval Milliseconds to wait after each try, default to 100ms + */ +async function retry( + task: { + run: () => Promise; + description: string; + }, + maxRetries: number = 10, + interval: number = 100, +): Promise { + let triesLeft = maxRetries; + let cart; + while (true) { + debug( + 'Try %s (%d/%d)', + task.description, + maxRetries - triesLeft + 1, + maxRetries, + ); + cart = await task.run(); + if (cart != null) return cart; + if (--triesLeft > 0) { + debug('Wait for %d ms', interval); + await sleep(interval); + } else { + // No more retries, timeout + const msg = `Fail to ${task.description} after ${maxRetries * + interval} ms`; + debug('%s', msg); + throw new HttpErrors.RequestTimeout(msg); + } + } +} + +/** + * Sleep for the given milliseconds + * @param ms Number of milliseconds to wait + */ +function sleep(ms: number) { + return new Promise(resolve => { + setTimeout(() => { + resolve(); + }, ms); + }); +} diff --git a/src/repositories/shopping-cart.repository.ts b/src/repositories/shopping-cart.repository.ts index b3f3bd777..ea46018da 100644 --- a/src/repositories/shopping-cart.repository.ts +++ b/src/repositories/shopping-cart.repository.ts @@ -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 + * transaction fails. See https://github.com/NodeRedis/node_redis#optimistic-locks */ async checkAndSet( userId: string, @@ -66,7 +69,7 @@ export class ShoppingCartRepository extends DefaultKeyValueRepository< if (!cart) return null; await execute('MULTI', []); await this.set(userId, cart); - await execute('EXEC', []); - return cart; + const result = await execute('EXEC', []); + return result == null ? null : cart; } } diff --git a/test/acceptance/shopping-cart.controller.acceptance.ts b/test/acceptance/shopping-cart.controller.acceptance.ts index dc84adb0a..844df5285 100644 --- a/test/acceptance/shopping-cart.controller.acceptance.ts +++ b/test/acceptance/shopping-cart.controller.acceptance.ts @@ -117,11 +117,21 @@ describe('ShoppingCartController', () => { await cartRepo.deleteAll(); } + function givenAnItem(item?: Partial) { + return new ShoppingCartItem( + item || { + productId: 'iPhone XS', + quantity: 2, + price: 2000, + }, + ); + } + function givenShoppingCart() { return new ShoppingCart({ userId: 'user-0001', items: [ - new ShoppingCartItem({ + givenAnItem({ productId: 'iPhone XS Max', quantity: 1, price: 1200, @@ -129,12 +139,4 @@ describe('ShoppingCartController', () => { ], }); } - - function givenAnItem() { - return new ShoppingCartItem({ - productId: 'iPhone XS', - quantity: 2, - price: 2000, - }); - } });