signal
signal

Reputation: 200

Avoiding race conditions, Django + Heroku + PostgreSQL

I'm running a contest site where you try to make click number X to win a prize. It's written in Django and running on Heroku with PostgreSQL. Each click is saved as an instance of a Play model, which calculates it's number by seeing how many plays are in the DB before it, and adds 1. This number is saved in the Play model. This is central to the whole site, as what number play you make determines whether or not you get a prize.

Recently, we had a case where 2 people got the winning number at the same time. Checking the database, I see there's actually about a 3% of plays that share their numbers. Oops. I've added 'unique_together' to the Play model's 'number' and 'game' field, so the DB will help me avoid repeated numbers in the future, but am worried that future race conditions might make the system skip some numbers, which would be bad if the numbers in question where winning numbers.

I've looked into locking the table, but worry that this might kill the sites concurrency (we currently have up to 500 simultaneous players, and expect much more in the future).

What strategy should I implement too be 100% sure I never have repeated or skipped numbers?

My Play class:

class Play(models.Model):
    token = models.CharField(unique=True, max_length=200)
    user = models.ForeignKey(User)
    game = models.ForeignKey(Game)
    datetime = models.DateTimeField(auto_now_add=True)
    winner = models.BooleanField(default=False)
    flagged = models.BooleanField(default=False)
    number = models.IntegerField(blank=True, null=True)
    ip = models.CharField(max_length=200, blank=True, null=True)

    def assign_number(self, x=1000):
        #try to assign number up to 1000 times, in case of race condition
        while x > 0:
            before = Play.objects.filter(id__lt=self.id, game=self.game)
            try:
                self.number = before.count()+1
                self.save()
                x=0
            except:
                x-=1

    class Meta:
        unique_together = (('user', 'game', 'datetime'), ('game','number'))

Upvotes: 0

Views: 586

Answers (1)

almalki
almalki

Reputation: 4785

A simple solution would be putting the counter and winner user in the Game model. You can then use select_for_update to lock the record:

game = Game.objects.select_for_update().get(pk=gamepk)
if game.number + 1 == X
    # he is a winner
    game.winner = request.user
    game.number = game.number + 1
    game.save()

else:
    # u might need to stop the game if a winner already decided

As part of the same transaction you can also record Player s objects so you also know who clicked and track other info but don't put the number and winner there. To use select_for_update you need to use postgresql_psycopg2 backend.

Update: Since django set autocommit on by default, you have to wrap the above code in atomic transaction. From django docs

Select for update If you were relying on “automatic transactions” to provide locking between select_for_update() and a subsequent >write operation — an extremely fragile design, but nonetheless possible — you must wrap the relevant code in atomic().

You can decorate your view with @transaction.atomic:

from django.db import transaction

@transaction.atomic
def viewfunc(request):
    # This code executes inside a transaction.
    do_stuff()

Upvotes: 1

Related Questions