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
Using
@transaction.atomic
decorator seems to resolve the issue, even if I can’t really prove this do works 100%.From Wikipedia:
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
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.