Search code examples
pythondjangodatabaseconcurrencyatomic

Ensure atomicity when saving a model


I am quite new to Django, and I am not sure how to manage concurrency.

I am building an application to reserve equipments. I have a Reservation model with a ManytomanyField of Equipments. When I create a new reservation, I have to ensure that all the equipments are available. The first version of my code doesn't care about concurrency:

def validate_reservation(keys, begin, end):

   eqpts = Equipment.objects.filter(pk__in=keys)
   if all(eqpt.is_available(begin, end) for eqpt in eqpts):
      res = Reservation(eqpts, begin, end)
      res.save()
      return True
   else:
      return False

The is_available method checks if there already are any reservation for the given dates:

  def is_available(self, begin, end):
        return not self.reservations.filter(begin__lte=end, end__gte=begin).exists()

The problem is that two users running this code may create Conflicting Reservations, if the first saves his reservation after the second checked the availability of the equipments.

I believe that I can solve the issue with transactions, so this I what I came up with:

def validate_reservation(keys, begin, end):

    with transaction.atomic():
       eqpts = Equipment.objects.select_for_update().filter(pk__in=keys)
       if all(eqpt.is_available(begin, end) for eqpt in eqpts):
          res = Reservation(eqpts, begin, end)
          res.save()
          return True
       else:
          return False

Does this have the intended behaviour ? How can I know if the transaction failed and notify the user?

Is there a different and better way ?


Solution

  • Yes, this should work as long as you consistently use select_for_update() before modifying anything that could affect your availability check.

    This code will not interfere with itself, since the select_for_update() will acquire row-level locks on the key rows at the beginning. But let's say you have another view that allows people to enter a reservation pk and change the beginning and ending times, or add a new piece of equipment. That could cause an inconsistency unless you explicitly do the same select_for_update() on the Equipment rows associated with that Reservation.

    Note that your check is not very efficient. Anytime you're executing queries in a loop you should look for an alternative. In your case you should be able to find conflicts with a single query (that JOINs the tables):

    has_conflict = Reservation.objects.filter(begin__lte=end,
                                              end__gte=begin,
                                              equipment__pk__in=keys).exists()
    

    (Of course, you still have the concurrency problem so you'll still need to acquire an appropriate lock or use a different transaction isolation level.)

    How can I know if the transaction failed and notify the user?

    The transaction will not fail, it will just block until the locks have been released. You can use select_for_update(nowait=True) (on some databases) if you want the transaction to fail instead.