Search code examples
djangopython-3.xdjango-modelsdjango-1.11database-concurrency

Django concurrency editing


I'm in a bit of muddle about how and where my concurrency editing functions should be implemented, so that a Mutex concurrency editing cannot be performed. My code:

models.py

class Order(models.Model):
    edit_version = models.IntegerField(default=0, editable=True) # For concurrency editing 

    ### Added for concurrency with 2 or more users wanting to edit the same form ###
    locked = models.BooleanField(default = False)
    def lock_edit(self):
        self.locked = True
        print ("locked_1: {0}".format(self.locked)) #Test purposes only
        super().save() # what's this doing exctly??

    def save_edit(self):
        self.locked = False
        print ("locked_2: {0}".format(self.locked)) #Test purposes only
        super().save()

view.py

@permission_required('myapp.edit_order', fn=objectgetter(Order, 'id'))
def edit_order(request,id = None):
    """
    """
    order = Order.objects.get(id=id)
    print ("order: {0}".format(order))
    print ("EDIT_VERSION: {0}".format(order.edit_version))

    if settings.USE_LOCKS:
        print("order.locked: {0}".format(order.locked))
        order.lock_edit()
        #order.locked = False # only to force the else clause for testing
        if order.locked:
            print ("Editing this form is prohibited because another user has already locked it.")
            messages.info(request, 'Editing this form is prohibited because another user has already locked it.') # TODO: Pop-up and redirect necessary
            return HttpResponseRedirect('/sanorder')
            #raise ConcurrencyEditUpdateError #Define this somewhere
        else:
            print ("Order lock is False")
            order.lock_edit()
            print("order.locked_new: {0}".format(order.locked))
            updated = Order.objects.filter(edit_version=order.edit_version).update(edit_version=order.edit_version+1)
            print ("UPDATED: {0}".format(updated))
            print ("EDIT_VERSION_NEW: {0}".format(order.edit_version))
            #return Order.objects.filter(edit_version=order.edit_version).update(edit_version=order.edit_version+1)
            return updated > 0



        ### Here are further functions in the form executed ###


        if updated > 0: # For concurrency editing
        order.save_edit()

    return render(request, 'myapp/order_edit.html',
        {'order':order,
            'STATUS_CHOICES_ALREADY_REVIEWED': dSTATUS_CHOICES_ALREADY_REVIEWED,
            'bolError': bolError,
            'formQuorum': formQuorum,
            'formCustomer': formCustomer,
            'formInfo': formInfo,

        })

The intention is, a user can access and edit a specific form, but only if no one else is editing it. Otherwise the user sees a pop-up message and is redirected to the main page. When the user is editing, a lock is triggered and released on submitting the form. In this case, this pertains to the lines:

    if updated > 0: # For concurrency editing
    order.save_edit()

However this is not working. Where am I going wrong? The intention is what should be a relative simple Mutex implementation. I'm trying to follow the example given here


Solution

  • The main problem I can see in your code is that aside from saving - you don't seem to be releasing the lock anywhere (also, I think that your indentation is broken in the original post, but that's not relevant, as I could guess the intention).

    In order to properly implement the lock, IMO you need to have a note at least on the following:

    • Who locked the model instance
    • When the lock will expire

    The general idea of how the lock will work in that case will be:

    • If you're the first user to start the edition - lock the model instance
    • If you're the lock owner, you can edit the lock (this is just in case the original editor accidentally closes the tab and wants to continue edition again)
    • If you're not the lock owner AND the lock did not yet expire, you cannot edit the model
    • If you're not the lock owner, BUT the lock has expired, you can edit it (now you're the lock owner).

    So, a pseudo-implementation would look like the following:

    The model:

    class Order(models.Model):
        LOCK_EXPIRE = datetime.timedelta(minutes=3)
    
        locked_by = models.ForeignKey(User, null=True, blank=True)
        lock_expires = models.DateTimeField(null=True, blank=True)
    
        def lock(self, user):
            self.locked_by = user
            self.lock_expires = datetime.datetime.now() + self.LOCK_EXPIRE
            self.save()
    
        def unlock(self):
            self.locked_by = None
            self.lock_expires = None
            self.save()
    
        def can_edit(self, user):
            return self.locked_by == user or self.lock_expires < datetime.datetime.now()
    

    The view:

    def edit_order(request, order_id = None):
        with transaction.atomic():
            # get_object_or_404 is just to avoid redundant '404' handling
            # .select_for_update() should put a database lock on that particular
            # row, preventing a race condition from happening
            order = get_object_or_404(Order.objects.select_for_update(), id=order_id)
    
            if not order.can_edit(request.user):
                raise Http403
    
            order.lock(request.user)
    
        # Handle form logic
        order.unlock()
        order.save()
    

    For further improvement, you could create a simple locking endpoint and put some JavaScript code on your website that would continuously (e.g. every minute) lock the order edition, which should keep the order locked until the person that locked it closes his tab. Alternatively (probably better than the above) would be to warn the user that his lock is about to expire and if he wants to extend it - that's up to you.