Devio's avatar
Level 2

Checkout system: how to handle multiple orders at same time for limited stock

I am building a checkout system for an ecommerce website. On checkout, my code checks if the stock is available and if so, hold the stock until the payment passes or fails.

Assume a product has only 1 item left in stock. Two customers add the item to cart and checkout at the same time. I know that there is deadlock prevention and one of the customers should receive an error. My issue is if this is a multithreaded server, is there a risk of both of them checking out successfully.

Is this something I should worry about when working with a Laravel backend?

Also is there any other concerns I should have about this?

1 like
15 replies
martinbean's avatar

@devio Well, you need to model inventory movements, and decide when inventory is reserved:

  • When a customer adds an item to their cart?
  • When a customer begins checkout?
  • When a customer successfully completes payment for an order?

Unfortunately there is no one single answer; which one you choose very much depends on what fits best for your business use case. But no matter which one you choose, you’re going to need to lock rows to prevent individual items being held by multiple people.

For example, if two people open a page for a product and it says there is one left in stock, and then your code just does something like…

$product->inventory()->update([
    'in_stock' => $product->in_stock - 1,
]);

…you may end up in a situation where you end up with a negative stock level after both requests update the inventory for the product.

You also need to account for reversals of stock adjustments. So if you say, reserve stock when a customer adds it to their cart, you need to ensure you put that stock “back” if the cart expires. For example, if you delete carts older than 2 days, you might have some code that does…

Cart::query()->where('updated_at', '<', Date::now()->subDays(2))->delete();

…while that will delete any stale carts, your stock level is not going to magically replenish; you need some code that will replenish your inventory with the same quantity that was held in those carts you’re purging.

2 likes
Devio's avatar
Level 2

@martinbean In my case stock is only held when a customer begins checkout. Assume I have a field stock in my products table that decreased when a customer checks out. The same field is used to check if a customer can checkout, i.e.,

  • loop cart items
  • check if product exists and it stock is >= to item quantity
  • through an error if the check fails
  • create order
  • loop cart items again
  • attach items to order and reduce stock

My issue is if the two customers check out at the same time, will they checkout successfully?

1 like
martinbean's avatar

@Devio Like I say, you need to ensure you’re locking rows when retrieving them, so that tow updates can’t affect the same row at the same time. Because the following is possible:

  • User 1 starts checks out
  • Code starts looping over user 1’s items
  • User 2 checks out whilst code for user 1 is running
  • Code starts looping over user 2’s items whilst looping over user 1’s items is still happening

So if you have two users whose items are being iterated over, the same counts may be read, and then you end up reserving the same stock, but for two different users. If there was only 1 of item A in stock, but two users began checking out, then you may reserve 1 of item A for two users, which just isn’t possible.

So, you need to ensure you’re locking any inventory-related records when checking and modifying them: https://laravel.com/docs/queries#pessimistic-locking

Basically, if user 1 checks out, then user 2 will not be able to read the row (and its updated value) until after user 1’s transaction has finished.

2 likes
Devio's avatar
Level 2

@martinbean does locking the record mean that the second PARALLEL request will WAIT until the record is free? I think this is what I do not understand

vincent15000's avatar

You don't have to worry about anything when working with a Laravel backend. The problem will be the same with all framworks and is not related to them.

- user 1 displays product A with stock 1
- user 2 displays product A with stock 1
- user 1 adds product A in the cart at the same time as user 2
- necessarily one of both will be handled before the other => the first to be handled will have the product in its cart and set the stock to 0 and throw an error for the second to be handled
3 likes
Snapey's avatar
Snapey
Best Answer
Level 122

Remember NOTHING happens at the same time.

But you can get yourself into a mess if you don't consider things happening in parallel.

  • Checkout user 1, load stock record, 1 in stock
  • Checkout user 2, load stock record, 1 in stock
  • deduct 1 from stock for user 1, save 0 to stock record.
  • deduct 1 from stock for user 2, save 0 to stock record.

No errors have occurred, stock is zero, but two people have orders for 1 item, only one of which can be fulfilled.

You have to think about ATOMIC instructions like increment and decrement to ensure stock balances change accurately, and if you don't want to over promise customers make sure you reserve stock and let them know when that reservation has expired.

3 likes
Devio's avatar
Level 2

@Snapey Could you share a simple example of how I would set this up so that it works correctly?

1 like
Snapey's avatar

@jlrdw lock for update is fine for protecting against writing data to a stale record, but what the OP is talking about is reserving stock

2 likes
Devio's avatar
Level 2

@jlrdw I don't think locking the record would work for my case since if I have enough stock for both users to check out in PARALLEL , user 2 would get an error since the record is locked. Is this right? Or does the second request WAIT until the record is free?

2 likes
Devio's avatar
Level 2

@Snapey since I'm only reserving the stock at checkout, I was thinking I could set up a job queue for this( so that the checkouts execute sequentially ) then broadcast the result of the checkout process to the frontend( similar to how you get a live message in a text app ). Would this work?

1 like
Devio's avatar
Level 2

I found another solution using Laravel cache and atomic locks. Thanks to @snapey for mentioning atomic instructions.

Cache::lock('checkout', 10)->block(3, function () {
        //todo
});

the above code will lock execution of subsequent requests until execution is done.

To read more on this check out Laravel Cache & Atomic Locks

1 like
Devio's avatar
Level 2

@Snapey I'm creating an order with a status of "PENDING" and reducing stock. If payment fails, the stock is added back and the order status is set to "FAILED". So I'm technically reserving stock during the payment window. If payment is successful, the order status is set to "PAID" and can be processed for shipping. Do you have a better approach? If so, Please share.

Snapey's avatar

@Devio seems ok, and it really depends how much cart abandonment there is. If the user creates a shopping cart and then just goes away, what happens to the stock?

I prefer to add the item to an order detail for the product with a basket timeout written to the same record. To know if there is stock available for another customer, take the current stock count and subtract the sum of that type of item in all currently valid baskets.

Please or to participate in this conversation.