Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Django: Transaction and select_for_update()

Tags:

python

django

In my Django application I have the following two models:

class Event(models.Model):
    capacity = models.PositiveSmallIntegerField()

    def get_number_of_registered_tickets():
        return EventRegistration.objects.filter(event__exact=self).aggregate(total=Coalesce(Sum('number_tickets'), 0))['total']


class EventRegistration(models.Model):
    time = models.DateTimeField(auto_now_add=True)
    event = models.ForeignKey(Event, on_delete=models.CASCADE)
    number_tickets = models.PositiveSmallIntegerField(validators=[MinValueValidator(1)])

The method get_number_of_registered_tickets() do I need at several places in my application (e.g. template rendering). So I thought it makes sense to put it into the model also because it's related to it and I often heard it's good to have "fat models and lightweight views".

My problem now: In order to prevent that two people want to register for the event in parallel, I have to use locking. Example: Let's say there's one ticket left to register for. Now, to people are on my website and click "Register" simultaneously. Under unforunate circumstances, it could happen that both requests are valid and now I have more registrations than capacity.

I'm relatively new to Django, but reading through the docs, I thought that select_for_update() should be the solution, am I right here (I use PostgreSQL, so that should be supported)?

However, the docs also say that using select_for_update() is only valid within a transcation.

Evaluating a queryset with select_for_update() in autocommit mode on backends which support SELECT ... FOR UPDATE is a TransactionManagementError error because the rows are not locked in that case. If allowed, this would facilitate data corruption and could easily be caused by calling code that expects to be run in a transaction outside of one.

My idea was now to change my model method get_number_of_registered_tickets() and add select_for_update():

def get_number_of_registered_tickets():
        return EventRegistration.objects.select_for_update().filter(event__exact=self).aggregate(total=Coalesce(Sum('number_tickets'), 0))['total']

Different questions now:

  1. Is using select_for_update() the right solution to my problem?
  2. Does it mean that I cannot use the method get_number_of_registered_tickets() in different views/templates now, given that it seems to only work within a transaction? Do I have to violate DRY here and copy and paste the query with select_for_update() to another place in my code?
  3. I tested it locally and Django does not raise the TransactionManagementError while being in autocommit mode (not using any transactions). What could be the reason or do I misunderstand something?
like image 309
Aliquis Avatar asked Sep 22 '18 08:09

Aliquis


People also ask

What is Select_for_update in Django?

The select_for_update method offered by the Django ORM solves the problem of concurrency by returning a queryset that locks all the rows that belong to this queryset until the outermost transaction it is inside gets committed thus preventing data corruption.

What are transactions in Django?

A transaction is an atomic set of database queries. Even if your program crashes, the database guarantees that either all the changes will be applied, or none of them. Django doesn't provide an API to start a transaction.

How to handle concurrency in Django?

You must synchronize access to the locks, consider fault tolerance, lock expiration, can locks be overridden by super users, can users see who has the lock, so on and so on. In Django, this could be implemented with a separate Lock model or some kind of 'lock user' foreign key on the locked record.

When to use select_ for_ update?

SELECT FOR UPDATE can be used to maximize database performance in the event of concurrent transactions working on the same rows, and the end result (in the case of CockroachDB) is still a database with serializable isolation.


1 Answers

Doing select_for_update() on an EventRegistration queryset isn't the way to go. That locks the specified rows, but presumably the conflict you're trying to prevent involves creating new EventRegistrations. Your lock won't prevent that.

Instead you can acquire a lock on the Event. Something like:

class Event(models.Model):
    ...
    @transaction.atomic
    def reserve_tickets(self, number_tickets):
        list(Event.objects.filter(id=self.id).select_for_update())  # force evaluation
        if self.get_number_of_registered_tickets() + number_tickets <= self.capacity:
            # create EventRegistration
        else:
            # handle error

Note that this uses the transaction.atomic decorator to make sure you are running inside a transaction.

like image 128
Kevin Christopher Henry Avatar answered Oct 13 '22 17:10

Kevin Christopher Henry