skip to Main Content

I’m working on an e-commerce website with a cart and products. A product’s primary key is added to the user’s session data in a dictionary 'cart_content'. This dictionary hold product’s primary key as key and amount of the given product as value.

I successfully reproduced a race condition bug by triggering a click() twice with an amount of product that will sold out for the next purchase. So the two requests increment twice the cart but there is not enough stock.

How can I prevent race condition to happen like the example given above or in a multi-user case ? Does there is for example some sort of lock system that prevent add_to_cart() from being executed asynchronously ?

core/models.py :

class Product(models.Model):
    …
    number_in_stock = models.IntegerField(default=0)
    
    @property
    def number_on_hold(self):
        result = 0
        for s in Session.objects.all():
            amount = s.get_decoded().get('cart_content', {}).get(str(self.pk))
            if amount is not None:
                result += int(amount)
        return result
    …

cart/views.py :

def add_to_cart(request):
    if (request.method == "POST"):
        pk = request.POST.get('pk', None)
        amount = int(request.POST.get('amount', 0))
        if pk and amount:
            p = Product.objects.get(pk=pk)
            if amount > p.number_in_stock - p.number_on_hold:
                return HttpResponse('1')
            if not request.session.get('cart_content', None):
                request.session['cart_content'] = {}
            if request.session['cart_content'].get(pk, None):
                request.session['cart_content'][pk] += amount
            else:
                request.session['cart_content'][pk] = amount
            request.session.modified = True
            return HttpResponse('0')
    return HttpResponse('', status=404)

cart/urls.py:

urlpatterns = [
    …
    path("add-to-cart", views.add_to_cart, name="cart-add-to-cart"),
    …
]

cart/templates/cart/html/atoms/add_to_cart.html :

<div class="form-element add-to-cart-amount">
    <label for="addToCartAmount{{ product_pk }}"> {% translate "Amount" %} : </label>
    <input type="number" id="addToCartAmount{{ product_pk }}" />
</div>
<div class="form-element add-to-cart">
    <button class="btn btn-primary button-add-to-cart" data-product-pk="{{ product_pk }}" data-href="{% url 'cart-add-to-cart' %}"><span> {% translate "Add to cart" %} </span></button>
</div>

cart/static/cart/js/main.js:

$(".button-add-to-cart").click(function(event) {
    event.preventDefault();
    let product_pk = $(this).data("productPk");
    let amount = $(this).parent().parent().find("#addToCartAmount" + product_pk).val();
    $.ajax({
        url: $(this).data("href"),
        method: 'POST',
        data: {
            pk: product_pk,
            amount: amount
        },
        success: function(result) {
            switch (result) {
                case '1':
                    alert(gettext('Amount exceeded'));
                    break;
                case '0':
                    alert(interpolate(gettext('Successfully added %s items to the cart.'), [amount]))
                    break;
                default:
                    alert(gettext('Unknown error'));
            }
        }
    });
});

The Javascript used to reproduce the race condition. Of course it doesn’t always work the first time. Just repeat two or three times until you get the behaviour I mentioned :

async function test() {
    document.getElementsByClassName('button-add-to-cart')[0].click();
}

test(); test();

2

Answers


  1. Chosen as BEST ANSWER

    Using @transaction.atomic decorator seems to resolve the issue, even if I can’t really prove this do works 100%.

    From Wikipedia:

    An atomic transaction is an indivisible and irreducible series of database operations such that either all occur, or none occur. A guarantee of atomicity prevents partial database updates from occurring, because they can cause greater problems than rejecting the whole series outright.

    @transaction.atomic
    def add_to_cart_atomic(request, pk, amount):
        p = Product.objects.get(pk=pk)
        if amount > p.number_in_stock - p.number_on_hold:
            return HttpResponse('1')
        if not request.session.get('cart_content', None):
            request.session['cart_content'] = {}
        if request.session['cart_content'].get(pk, None):
            request.session['cart_content'][pk] += amount
        else:
            request.session['cart_content'][pk] = amount
        request.session.modified = True
        return HttpResponse('0')
    
    
    def add_to_cart(request):
        if (request.method == "POST"):
            pk = request.POST.get('pk', None)
            amount = int(request.POST.get('amount', 0))
            if pk and amount:
                return add_to_cart_atomic(request, pk, amount)
        return HttpResponse('', status=404)
    

  2. THe one of the easiest way of handling race conditions is to incorporate pessimistic locks on database level. To do so, you need to wrap the operation into the database transaction and set up a lock on the object that will be common for this kind of operations.

    In your case, if you store your session in the database you can do something like that

    from django.db import transaction
    
    
    @transaction.atomic # decorate function with transaction.atomic or use it as a context manager
    def add_to_cart(session, product_id, amount):
        p = Product.objects.get(pk=product_id)
        s = Session.objects.select_for_update().get(pk=session.pk) # NOTE: select_for_update() here
        if amount > p.number_in_stock - p.number_on_hold:
            return HttpResponse('1')
        if not request.session.get('cart_content', None):
            request.session['cart_content'] = {}
        if request.session['cart_content'].get(pk, None):
            request.session['cart_content'][pk] += amount
        else:
            request.session['cart_content'][pk] = amount
        request.session.modified = True
    
    
    def add_to_cart(request):
        if (request.method == "POST"):
            pk = request.POST.get('pk', None)
            amount = int(request.POST.get('amount', None))
            if pk and amount:
                add_to_cart(request.session, pk, amount) # <-- moved logic for cleaner code
                return HttpResponse('0')
        return HttpResponse('', status=404)
    

    If you have authenticated user id it would be better to use it instad of Session.

    Please take into account that set a lock on global object such as Session or User may lead to decreased troughput of your backend, since requests will have to queue on after another.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search