4

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

Peter Sobhi
  • 2,330
  • 2
  • 19
  • 27
pymat
  • 858
  • 15
  • 31

1 Answers1

5

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.

samu
  • 2,437
  • 11
  • 25
  • Thank you, looks like this would be in the right direction for what I'm looking for. Just one thing: I understand that the select_for_update() cannot be implemented if the DB is sqlite3, right? – pymat May 08 '18 at 08:50
  • No, it's not supported in sqlite3. It does not error out, however - `select_for_update()` simply does nothing on sqlite3. – samu May 08 '18 at 08:54
  • What would you recommend as an alternative in this case? – pymat May 08 '18 at 08:55
  • 1
    Not to use sqlite. It's good for small, preferably single-user applications, which makes it good enough for local development. But in your case you want to use advanced locking mechanisms, which sqlite3 lacks support of. AFAIK you can lock an entire database in sqlite, which WILL prevent any race conditions from happening, but it will do so by preventing ANY user activity in the meantime - so in my opinon it's not a feasible solution. – samu May 08 '18 at 08:59
  • 1
    Alternativelly - you can just ignore that and live with the fact that a race condition IS possible in your app. The worst thing that will happen in this case is that the user opens a page "thinking" that he can edit it, but in reality somebody else stole his lock. This would require two users to access the same order edition at the exact same time. While it's a bug, it's not that serious (considering that it's not a security-critical feature), and it's up to you if you want to migrate to a different DB to fix it. – samu May 08 '18 at 09:02
  • "... and put some JavaScript code on your website..." that is also on my todo....I'm a novice in JavaScript so as of yet not sure how to implement that. My original intention was to have a simple pop-up that notifies a user if this particular form is locked. – pymat May 08 '18 at 09:20
  • So in the example I gave you, just add add another variable to the context of the template you're rendering: `is_locked = order.can_edit(request.user)`, and remove my `raise Http403` statement. That way you will have a boolean in your template, which you can use to render your popup (or use the `alert()` JavaScript function). – samu May 08 '18 at 09:23
  • Thanks again. I noticed that you used "with transaction.atomic()". Is this preferred (with the "'ATOMIC_REQUESTS': True" in settings.py) over the decorator @transaction.atomic? – pymat May 08 '18 at 13:07
  • The reason why I used it is because `select_for_update()` blocks the resource until the end of the transaction. I wanted to lock the database for as short as I could, so only for the check + for setting the edit lock. If you're using `ATOMIC_REQUESTS`, I don't think that you need to explicitly start the transaction. – samu May 08 '18 at 13:17
  • But I have to use the `ATOMIC_REQUESTS` in any case simply because of the `with transaction.atomic()`? Sorry if I sound confused, I'm new to this area of Django, your last sentence is not super clear to me. – pymat May 08 '18 at 13:22
  • No, you are not required to use `ATOMIC_REQUESTS`. I explicitly started a transaction here to make it as short as possible. – samu May 08 '18 at 13:30
  • It seems that not using sqlite is not an option, as the code should be backend independent. When I currently have the `order = get_object_or_404(Order.objects.select_for_update(), id=order_id)` left in, then I receive a `Page not found (404)`. Is there a more universal method to apply here? – pymat May 09 '18 at 09:33
  • Try replacing that with `order = Order.objects.select_for_update().get(id=order_id)` and see if you have any traceback. It looks like simply your order id does not exist in the DB. – samu May 09 '18 at 09:38
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/170675/discussion-between-samu-and-pymat). – samu May 09 '18 at 09:38
  • Without even reaching the popup, I'm testing the lock. The problem I see is that as soon as the `edit` page is entered, the `can_edit`, `lock_edit` AND the `unlock_edit` are all executed in one shot. I then moved the `order_lock.unlock_edit() order_lock.save()` to right at the end of the `view` method, just before the `return render` is called. This didn't change anything either. It is expected that only the `can_edit` and `lock_edit` are called, releasing the lock (`unlock_edit`) once the use clicks on "save changes" (or the lock simply expires). How can I get around this? – pymat May 10 '18 at 10:48
  • In your comment on May 8 at 9:23, you wrote about adding another variable to the context. I get the logic of this, however the `order.lock(request.user)` is then called anyway. This can't be called if `is_locked == True`. At present I'm still not getting this to work, probably because I need a workaround for this, and perhaps the `unlock` is currently in the wrong place (i.e. in the if clause: `if request.method == 'POST'`). I noticed too when testing this, that in order to login with 2 users simultaneously, I need to different Webbrowsers, for example, Safari and Chrome. – pymat May 16 '18 at 06:42