Concurrency, Django and Apache Bench
The motivation for this blog was as a starting point in debugging errors caused errors caused by concurrency or race conditions - the so-called Heisenbug.
I am going to use a contrived example of a bank account with a method to withdraw money and record each transaction. Then I will make a number of concurrent requests and investigate any undesired effects. I do not, in this blog, seek to propose any solution to these effects - solely to provide a means of determining whether they are present.
The app for this demo can be found here.
Django App
For this task I will use a simple Django App with Session Authentication, a GET endpoint for retrieving the account balance, and a POST endpoint for making a withdrawl. I have chosen to use a POST request for the withdrawl method to illustrate how to use Apache Bench (Ab) with cookies.
Models
The simple account model is associated to a user, has a minimum and the current balance. The transaction model is used as a record the balance at each transaction from the account.
from django.contrib.auth.models import User
from django.db import models
class Account(models.Model):
user = models.ForeignKey(User, models.SET_NULL, null=True, blank=True)
minimum = models.IntegerField(default=0)
current = models.IntegerField(default=1000000)
def make_withdrawl(self, value=1):
if self.current >= self.minimum:
self.current -= value
self.save()
return self.current
class Transaction(models.Model):
holder = models.ForeignKey(Account, models.SET_NULL, null=True, blank=True)
transaction = models.IntegerField()
balance = models.IntegerField()
Views
The views are really basic. The GET simply returns the current balance. The POST makes a withdrawl (default 1) and then creates the transaction.
from django.http import HttpResponse
from django.views import View
from .models import Account, Transaction
class WithdrawView(View):
def get(self, request, *args, **kwargs):
if request.user.is_authenticated:
holder = Account.objects.filter(user=request.user).first()
current = holder.current
return HttpResponse(current)
return HttpResponse("")
def post(self, request, *args, **kwargs):
holder = Account.objects.filter(user=request.user).first()
current = holder.make_withdrawl()
Transaction.objects.create(
holder=holder,
transaction=current
)
return HttpResponse(current)
Django and Gunicorn
For this type of experiment, it is probably best to run Django as you would in production, using a WSGI HTTP server such as Gunicorn. Whilst Django’s built in runserver is by default multithreaded, it seems prudent to keep the system close to that of a production system.
Benchmark
As described above, I have decided to have the withdrawl endpoint as an POST request. This means that I need to get CSRF tokens and session ids before using Ab. These commands are all contained in the script splatter.sh
.
Cookies
First we GET the login page and save the cookies to a file:
curl -c cookies.txt -XGET http://localhost:8001/auth/login/ 2>&1
Then grab the CSRF token from the cookies as so
CSRF=$(cat cookies.txt | grep csrftoken | awk -F" " '{print $7}')
Using the token we can login saving a new cookies file, which now includes the sessionid:
curl -i --cookie cookies.txt -H "X-CSRFToken:"$CSRF"" -H"Content-Type: application/x-www-form-urlencoded" -X POST -d "username=$UNAME" -d "password=$PWORD" http://localhost:8001/auth/login/ -c new_cookies.txt
As with the CSRF token we can grab the sessionid:
SESSION=$(cat new_cookies.txt | grep session | awk -F" " '{print $7}')
Benchmark
Now done to the serious business of using Ab to make a number of concurrent requests. This will make a total of 100 requests (-n 100
) with 3 concurrent requests (-c 3
). Note that I had to combine my cookies together separated with a semicolon.
ab -n 100 -c 3 -C "sessionid=$SESSION;csrftoken=$CSRF" -H "X-CSRFToken:$CSRF" -T"Content-Type: application/json" -mPOST http://localhost:8001/accounts/withdraw/
What does this tell us? First, we have created 100 transactions (Transaction.objects.count()
). Second, the current value on the Account model is only 999953 where as you’d hope after 100 withdrawls of value 1 you’d be at 999900. We can count the number of single transactions by grouping the transactions by balance and aggregating, as follows:
Transaction.objects.values("balance").annotate(count=Count("id")).aggregate(Count("count"))
Finally …
So there we have it. We can use Apache Bench to make a number of concurrent HTTP requests and observe it’s effect on out application.