Skip to content
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
118 changes: 68 additions & 50 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 6 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,17 +52,17 @@
"src"
],
"dependencies": {
"@loopback/boot": "^0.13.2",
"@loopback/boot": "^0.13.5",
"@loopback/context": "^0.12.8",
"@loopback/core": "^0.11.8",
"@loopback/core": "^0.11.9",
"@loopback/dist-util": "^0.3.7",
"@loopback/openapi-v3": "^0.13.2",
"@loopback/repository": "^0.16.2",
"@loopback/rest": "^0.21.1",
"@loopback/openapi-v3": "^0.14.2",
"@loopback/repository": "^0.16.5",
"@loopback/rest": "^0.22.2",
"bcryptjs": "^2.4.3",
"isemail": "^3.1.3",
"loopback-connector-kv-redis": "^3.0.0",
"loopback-connector-mongodb": "^3.6.0"
"loopback-connector-mongodb": "^3.7.1"
},
"devDependencies": {
"@commitlint/cli": "^7.1.2",
Expand Down
25 changes: 23 additions & 2 deletions src/controllers/shopping-cart.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,18 @@
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {put, get, del, param, requestBody, HttpErrors} from '@loopback/rest';
import {
put,
get,
del,
param,
requestBody,
HttpErrors,
post,
} from '@loopback/rest';
import {repository} from '@loopback/repository';
import {ShoppingCartRepository} from '../repositories';
import {ShoppingCart} from '../models';
import {ShoppingCart, ShoppingCartItem} from '../models';

/**
* Controller for shopping cart
Expand Down Expand Up @@ -59,4 +67,17 @@ export class ShoppingCartController {
async remove(@param.path.string('userId') userId: string) {
await this.shoppingCartRepository.delete(userId);
}

/**
* Add an item to the shopping cart for a given user
* @param userId User id
* @param cart Shopping cart item to be added
*/
@post('/shoppingCarts/{userId}/items')
async addItem(
@param.path.string('userId') userId: string,
@requestBody({description: 'shopping cart item'}) item: ShoppingCartItem,
) {
await this.shoppingCartRepository.addItem(userId, item);
}
}
8 changes: 6 additions & 2 deletions src/models/shopping-cart.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ import {Entity, model, property} from '@loopback/repository';
* Item in a shopping cart
*/
@model()
export class ShoppingCartItem {
export class ShoppingCartItem extends Entity {
/**
* Product id
*/
@property()
@property({id: true})
productId: string;
/**
* Quantity
Expand All @@ -25,6 +25,10 @@ export class ShoppingCartItem {
*/
@property()
price?: number;

constructor(data?: Partial<ShoppingCartItem>) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Is the partial needed in this case since this isn't a model directly exposed over REST and won't face issues of trying to create an instance without a productId?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not a big deal as the data itself is optional. So we can create a empty instance and populate the data afterward. The required constraint should be enforced by model validation.

Copy link
Member

Choose a reason for hiding this comment

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

The required constraint should be enforced by model validation.

@raymondfeng Please note that KeyValue repository does not perform validation right now! See lib/kvao/set.js. I think this is an oversight we should eventually fix.

super(data);
}
}

@model()
Expand Down
57 changes: 56 additions & 1 deletion src/repositories/shopping-cart.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,69 @@
// License text available at https://opensource.org/licenses/MIT

import {DefaultKeyValueRepository} from '@loopback/repository';
import {ShoppingCart} from '../models/shopping-cart.model';
import {ShoppingCart, ShoppingCartItem} from '../models/shopping-cart.model';
import {RedisDataSource} from '../datasources/redis.datasource';
import {inject} from '@loopback/context';
import {promisify} from 'util';

export class ShoppingCartRepository extends DefaultKeyValueRepository<
ShoppingCart
> {
constructor(@inject('datasources.redis') ds: RedisDataSource) {
super(ShoppingCart, ds);
}

/**
* Add an item to the shopping cart with optimistic lock to allow concurrent
* `adding to cart` from multiple devices
*
* @param userId User id
* @param item Item to be added
*/
addItem(userId: string, item: ShoppingCartItem) {
const addItemToCart = (cart: ShoppingCart | null) => {
cart = cart || new ShoppingCart({userId});
cart.items = cart.items || [];
cart.items.push(item);
return cart;
};
return this.checkAndSet(userId, addItemToCart);
}

/**
* Use Redis WATCH and Transaction to check and set against a key
* See https://redis.io/topics/transactions#optimistic-locking-using-check-and-set
Copy link
Member

@bajtos bajtos Sep 20, 2018

Choose a reason for hiding this comment

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

@raymondfeng IIUC, the algorithm outlined in the linked page is expecting to re-run the transaction if a race condition was detected, and repeat those re-run until the transaction succeed.

Using the above code, if there are race conditions and another client modifies the result of val in the time between our call to WATCH and our call to EXEC, the transaction will fail.
We just have to repeat the operation hoping this time we'll not get a new race. This form of locking is called optimistic locking and is a very powerful form of locking. In many use cases, multiple clients will be accessing different keys, so collisions are unlikely – usually there's no need to repeat the operation.

I don't see that logic implemented here. Are we expecting the REST clients to retry the operation? Could you please explain how are we signaling the race condition to them?

*
* Ideally, this method should be made available by `KeyValueRepository`.
*
* @param userId User id
* @param check A function that checks the current value and produces a new
* value. It returns `null` to abort.
*/
async checkAndSet(
userId: string,
check: (current: ShoppingCart | null) => ShoppingCart | null,
) {
const connector = this.kvModelClass.dataSource!.connector!;
// tslint:disable-next-line:no-any
const execute = promisify((cmd: string, args: any[], cb: Function) => {
return connector.execute!(cmd, args, cb);
});
/**
* - WATCH userId
* - GET userId
* - check(cart)
* - MULTI
* - SET userId
* - EXEC
*/
await execute('WATCH', [userId]);
let cart: ShoppingCart | null = await this.get(userId);
cart = check(cart);
if (!cart) return null;
await execute('MULTI', []);
await this.set(userId, cart);
await execute('EXEC', []);
return cart;
}
}
Loading