• Schema Book
  • Shopping Cart with No Product Reservation

Shopping Cart with No Product Reservation

Metadata, courtesy of http://www.wpclipart.com/working/work_supplies/shopping_cart_racing.png.html

This shopping cart schema more closely resembles the amazon shopping cart. When you add products to the cart, they are not reserved. The actual check for product inventory is only performed when the user checks out the cart.

Schema Observations

  • No locking up of product inventory until the checkout process allows for simple scaling of shopping cart schema.
  • Amazon style shopping cart.
  • No reservations means the user will not know if reservation of product inventory was successful until checkout time.

Schema

The schema we will be using is a stripped down version of what a real e-commerce system would use. It serves the purpose of illustrating the concepts needed to design a shopping cart with no product reservation. There are four collections we are interested in for our schema. These are the carts, inventories, orders and products collections.

Let’s look at an example cart that contains one product.

{
    "_id": ObjectId("54fd7392742abeef6186a68e")
  , "state": "active"
  , "modifiedOn": ISODate("2015-03-11T12:03:42.615Z")
  , "products": [{
        "_id": ObjectId("54fd7392742abeef6186a68e")
      , "quantity": 1
      , "name": "JC Sneaker"
      , "price": 100
    }]
}

Example inventory document that contains one reservation

{
    "_id": ObjectId("54fd7392742abeef6186a68e")
  , "quantity": 999
  , "reservations": [{
        "_id": ObjectId("54fd7392742abeef6186a68e")
      , "quantity": 1
      , "createdOn": ISODate("2015-03-11T12:03:42.615Z")
    }]
}

The main difference from the previous cart schema is that we do not reserve any product inventory until the very end when the user checks out the cart.

The last two collections are the products and order collection. The products collection contains the product information and the orders collection contains the final order document resulting from a successful shopping cart checkout.

Below is an example product document.

{
    "_id": ObjectId("54fd7392742abeef6186a68e")
  , "name": "JC Sneaker"
  , "properties": {}
}

Example order document with a single product.

{
    "_id": ObjectId("54fd7392742abeef6186a68e")
  , "total": 100
  , "shipping": {}
  , "payment": {}
  , "products": [{
        "_id": ObjectId("54fd7392742abeef6186a68e")
      , "quantity": 1
      , "name": "JC Sneaker"
      , "price": 100
    }]
}

Operations

Adding a product to the Shopping Cart

When the user attempts to reserve a quantity of a particular product in our application we just need to perform a single step in comparison to the reservation based cart schema.

Simply add the item to the products array in the cart document for the user creating the cart if one does not already exist.

Let’s take a look at how we go about adding the product to the cart. We are going to make some assumptions to simplify the example code. Those assumptions are that we have a userId of 1 and a productId of 111445GB3;

var quantity = 1;
var userId = 1;
var productId = "111445GB3";

var col = db.getSisterDB("shop").carts;
col.update(
    { _id: userId, state: 'active' }
  , {
      $set: { modifiedOn: new Date() }
    , $push: { products: {
        _id: productId
      , quantity: quantity
      , name: "Simsong Mobile"
      , price: 1000
    }}
  }, true);

Example 1: Add product to shopping cart

The updated statement above performs an upsert, meaning the document is created if one does not already exist for the provided userId and state.

Updating the Reservation Quantity for a Product

If the user decides to change the quantity for a specific product in the cart, we only need to change the product quantity in the cart.

var quantity = 2;
var userId = 1;
var productId = "111445GB3";

var col = db.getSisterDB("shop").carts;
col.update(
    { _id: userId, "products._id": productId, state: 'active' }
  , {
      $set: {
          modifiedOn: new Date()
        , "products.$.quantity": quantity
      }    
    }
  }, true);

Example 2: Update product quantity in cart

There is no need to update any inventory for this case or perform any rollbacks.

Checkout

The core of the logic here is in the checkout method. At the time of checkout, we need to attempt to reserve all requested inventory for all products in the cart.

var userId = 1;

var inventories = db.getSisterDB("shop").inventories;
var carts = db.getSisterDB("shop").carts;
var orders = db.getSisterDB("shop").orders;

var cart = carts.find({_id: userId});
var success = [];
var failed = [];

for(var i = 0; i < cart.products.length; i++) {
  var product = cart.products[i];

  var result = inventories.update({
      _id: product._id, quantity: {$gte: product.quantity }
    }, {
        $inc: {quantity: -product.quantity}
      , $push: {
        reservations: {
          quantity: product.quantity, _id: cart._id, createdOn: new Date()
        }
      }
    })

  if(result.nModified == 0) failed.push(product);
  else success.push(product);
}

Example 3: Attempt to reserve the inventory

For each product in the shopping cart, we attempt to reserve inventory for it. If the reservation attempt fails, we add the failed product to the array called failed and if it’s successful, we add the product to the success array.

In the end, if there are any products in the failed array, we need to rollback all the successful reservations into the inventories collection.

if(failed.length > 0) {
  for(var i = 0; i < success.length; i++) {
    inventories.update({
        _id: success[i]._id
      , "reservations._id": cart._id
    }, {
        $inc: { quantity: success[i].quantity }
      , $pull: { reservations: { _id: cart._id }}
    })
  }

  return  
}

Example 4: Rollback inventory reservation attempt

To rollback we simply pull the reservations out of the reservations array.

If we succeeded in reserving all the products we create an order document, set the cart to complete and release all the reservations from the inventories collection.

orders.insert({
    created_on: new Date()
  , shipping: {
      name: "Joe Dow"
    , address: "Some street 1, NY 11223"
  }
  , payment: { method: "visa", transaction_id: "2312213312XXXTD" }
  , products: cart.products
});

carts.update({
  _id: cart._id, state: 'active'
}, {
  $set: { state: 'completed' }
});

inventories.update({
  "reservations._id": cart._id
}, {
  $pull: { reservations: { _id: cart._id } }
}, false, true);

Example 5: Finish up a successful checkout

Cleaning up after incomplete checkouts

However, if during the checkout process something happened that ended the checkout process before it finished, we need to clean up the state of the reservations.

var cutOffDate = new Date();
cutOffDate.setMinutes(cutOffDate.getMinutes() - 30);

var cartsCol = db.getSisterDB("shop").carts;
var productCol = db.getSisterDB("shop").products;

var carts = cartsCol.find({
    modifiedOn: { $lte: cutOffDate }, state: 'active'
  });

while(carts.hasNext()) {
  var cart = carts.next();

  for(var i = 0; i < cart.products.length; i++) {
    var product = cart.products[i];

    productCol.update({
        _id: product._id
      , "reservations._id": cart._id
      , "reservations.quantity": product.quantity
    }, {
        $inc: { quantity: product.quantity }
      , $pull: { reservations: { _id: cart._id }}
    });
  }

  cartsCol.update({
    _id: cart._id
  }, {
    $set: { status: 'expired' }
  });
}

Example 6: Cleanup carts

To do this, we simply iterate over all the carts and their products and return each of the quantities to the product inventory.

Indexes

For the carts collection we reference carts by the state field to find expired carts. An index can be added for this field to make the query more efficient.

var col = db.getSisterDB("catalog").carts;
col.ensureIndex({state:1});

Example 7: Create the state field index

For the inventories collection, we look up embedded documents in the reservations array by the _id field. We need to create a multi key index for this field.

var col = db.getSisterDB("catalog").inventories;
col.ensureIndex({"reservations._id":1});

Example 8: Create the reservations._id field index

For the products and orders collections we do not need any other indexes than the default _id for our examples.

Scaling

Secondary Reads

All the operations against the cart are write operations so secondary reads are not useful for this schema as they might show out of date carts.

Sharding

Sharding the carts is fairly straight forward as each cart is self-contained. The easiest shard key for this is a hashed _id key as it allows for writing to be distributed as randomly as possible leveraging all the shards.

var admin = db.getSisterDB("admin");
db.runCommand({enableSharding:'shop'});
db.runCommand({
    shardCollection: 'shop.carts'
  , key: {_id:'hashed'}
});

Example 9: Hashed _id shard key

Performance

A simple exploration of the performance on a single machine with MongoDb 3.0 shows the difference between MMAP and WiredTiger for a narrow simulation using the schema simulation framework mongodb-schema-simulator.

Scenario

https://github.com/christkv/mongodb-schema-simulator/blob/master/examples/scripts/single_or_replset/cart_no_reservation/cart_5_item_no_reservation_successful_scenario.js

MongoDb runs locally on a MacBook Pro Retina 2015 with ssd and 16 gb ram. The simulation runs with the following parameters against a single mongodb instance under osx 10.10 Yosemite.

successfully checkout cart parameters

Parameters Value
processes 4
poolSize per process 50
type linear
Resolution in milliseconds 1000
Iterations run 25
Number of products in cart 5
Size of each product in bytes 1024
Number of users 1000
Execution strategy slicetime

MMAP

The MMAP engine is run using the default settings on MongoDB 3.0.1.

Successfully Checkout Cart

successfully checkout cart

Statistics Value
Runtime 61.878 seconds
Mean 23696.635 milliseconds
Standard Deviation 12515.697 milliseconds
75 percentile 35072.337 milliseconds
95 percentile 38497.619 milliseconds
99 percentile 40150.439 milliseconds
Minimum 21.637 milliseconds
Maximum 43277.439 milliseconds

WiredTiger

The WiredTiger engine is run using the default settings on MongoDB 3.0.1.

Successfully Checkout Cart

successfully checkout cart

Statistics Value
Runtime 62.607 seconds
Mean 26075.901 milliseconds
Standard Deviation 12410.047 milliseconds
75 percentile 36122.769 milliseconds
95 percentile 39481.372 milliseconds
99 percentile 41308.47 milliseconds
Minimum 20.228 milliseconds
Maximum 44646.7 milliseconds

This is an update heavy schema and this impacts WiredTiger more than MMAP due to the need to rewrite the document on each update in WiredTiger. Also note that the total runtime is higher than the 25 seconds of load we apply, pointing to the fact that the system is overloaded for this hardware configuration.

Notes

This seems to be a fairly common pattern for shopping carts now as it simplifies the scaling of the shopping cart by avoiding any inventory validation until the user actually intends to checkout the reserved products. It avoids inventory being locked up in carts that are abandoned.