Hi Abraham,
On 02/17/2015 10:01 PM, Abraham Varricatt wrote:
> I'm trying to make an app where folks can order X quantity of an item.
> The condition is that the order should only be made if inventory exists.
> Assume that we have stock of Y items. This means that only if Y >= X
> should we allow the sale to go through. And once we accept an order, the
> inventory should be updated so that Y = Y - X.
In general, the construct you need is called a "transaction", which
ensures that a series of database operations will either all be
committed together, or rolled back if they can't be successfully
completed. The Django API for that is "django.db.transaction.atomic".
If we're only considering the changes to your ShopItem model, you don't
even need an explicit transaction to avoid race conditions, because (as
the docs you linked show) the operation can be completed in a single
database query, which is inherently atomic. This is how it would look to
do it in a single query:
from django.db.models import F
def customer_buy(name, number):
ShopItem.objects.filter(
name=name).update(quantity=F('quantity')-number)
You want this to fail if someone tries to purchase a larger quantity
than are available. The best way to do this is via a database-level
"check constraint" on the column, such that the database itself will
never permit a negative quantity. If you make 'quantity' a
PositiveIntegerField (the name is wrong, it actually allows zero too) on
your model, and your database is PostgreSQL (or Oracle), Django will add
this constraint for you automatically. Then if someone tries to purchase
more than are available, you'll get an IntegrityError, which you'd want
to catch and handle in some way:
from django.db import IntegrityError
from django.db.models import F
class InsufficientInventory(Exception):
pass
def customer_buy(name, number):
try:
ShopItem.objects.filter(
name=name).update(quantity=F('quantity')-number)
except IntegrityError:
# signal to the calling code that the purchase failed - the
# calling code should catch this exception and notify the
# user that the purchase failed due to lack of inventory,
# and tell them the updated available quantity
raise InsufficientInventory()
You also want this function to handle the case where the given product
name doesn't exist. To help with this case, the `update` method returns
the number of rows updated:
from django.db import IntegrityError
from django.db.models import F
class InsufficientInventory(Exception):
pass
def customer_buy(name, number):
try:
updated = ShopItem.objects.filter(
name=name).update(quantity=F('quantity')-number)
except IntegrityError:
raise InsufficientInventory()
if not updated:
# Here we reuse Django's built-in DoesNotExist exception;
# you could define your own exception class instead.
raise ShopItem.DoesNotExist()
With this code, you've solved the bad race conditions -- quantity will
never go negative, and a sale will never appear to succeed when it
should have failed, because of two users submitting an order simultaneously.
There is still a sort of higher-level race condition that can happen
when two people both load the order page at the same time, and then one
of them orders first. This type of race condition is basically
impossible to avoid in a web app -- the best you can usually do is
simply let a user know that their order failed because someone else
ordered that item first while they were filling out the order form.
(In general, the term for this approach is "optimistic locking", because
you are "optimistically" allowing concurrent uses and hoping they don't
conflict, but if they do, you catch it and alert the user so they can
try again. The alternative is to lock everyone else out of the order
page while one user is ordering; this is called "pessimistic locking".
Usually optimistic locking is preferable, presuming the most common case
is "no conflict" -- e.g., enough inventory that both users can
successfully complete their order -- and you can handle conflicts in
such a way that the user whose transaction fails doesn't lose all their
work.)
In a real shopping-cart scenario, it's likely that you need to do more
than just update the quantity field on your ShopItem model -- you may
need to create an Order object too, and you want the Order object and
the ShopItem quantity decrement to both succeed or fail together; you
never want one of them to succeed and the other one to fail. This is the
scenario where you need to wrap both operations in `transaction.atomic`;
you can look it up in the docs to see how it's used.
Hope this helps,
Carl