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".

> The way I've currently implemented this is with a pair of getter/setter
> methods. I have a model 'ShopItem' which has two fields called
> 'quantity' and 'name' (this is unique). I've made a utility class
> (outside model source file) where I've made the following 2 functions;
> 
> def get_quantity(name):
>   stuff_left = 0
>   try:
>     item = ShopItem.objects.get(name=name)
>     stuff_left = item.quantity
>   except ShopItem.DoesNotExist:
>     pass # return zero
>   return stuff_left
> 
> def set_quantity(name, stuff_left):
>   item = ShopItem.objects.get(name=name)
>   item.quantity = stuff_left
>   item.save()
> 
> 
> Elsewhere in my project, if I need to display the remaining quantity of
> an item in a view, I'll pass the result from get_quantity(). At sale
> time here is how I update things;
> 
> def customer_buy(name, number):
>   temp = get_quantity(name)
>   if (temp >= number):
>     temp = temp - number
>     set_quantity(name, temp)
>     // do other things
>   else:
>     // sale failed, do something else
> 
> I tested this on my system and things appear to work. But I'm concerned
> about race conditions. What if two different customers came in to buy
> the same item? As in; if I only have 7 items in stock, but one customer
> came to buy 6nos and another 5nos. There is a chance (as per my
> understanding) that both orders will be accepted - even worse, my
> inventory will not accurately reflect the updated situation. 
> 
> Any ideas on how this issue can be resolved? I've come across this
> - 
> https://docs.djangoproject.com/en/1.6/ref/models/instances/#updating-attributes-based-on-existing-fields
> , but not sure how to apply it to the current scenario. 

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

-- 
You received this message because you are subscribed to the Google Groups 
"Django users" group.
To unsubscribe from this group and stop receiving emails from it, send an email 
to django-users+unsubscr...@googlegroups.com.
To post to this group, send email to django-users@googlegroups.com.
Visit this group at http://groups.google.com/group/django-users.
To view this discussion on the web visit 
https://groups.google.com/d/msgid/django-users/54E4DA7F.5090902%40oddbird.net.
For more options, visit https://groups.google.com/d/optout.

Attachment: signature.asc
Description: OpenPGP digital signature

Reply via email to