Sunday, December 26, 2021

Eliminating inter-request race conditions

Hi all.

I've been using Django for quite a number of years now, in various ways. Most of the time, I find myself needing to create custom solutions to solve what appears to be a very common problem. 

During the Christmas downtime, I decided to scratch this itch, and am putting together what will hopefully turn into a solution to what I'll describe below. I'm writing this here to get a sense of what the Django community sees in this: is this a niche problem, is it shared by a few others, or is the lack of these features a fundamental overnight in the core Django product?

The problems (from highest to lowest priority):

1) a form is rendered, the data is changed by a different task/request, then the form is submitted, overwriting the recent changes.

Whenever models can be modified by multiple users (or even the same user in different windows/tabs of their browser), this can happen. Also, if there are any background processes which can modify the data (e.g. celery, or various data synchronisation services), it's possible.
In some situations this is no big deal, as the users do not really care, or you know that the latest data would overwrite the previous data anyway. But in general, this is a major risk, particularly when dealing with any health or financial data. 

2) Not being able to safely lock a model/queryset beyond the lifetime of the request.

This is related to problem 1, and solving problem 2 may in some circumstances solve problem 1 - but not always. For example, depending on how the lock is implemented, a "rogue" task/request may bypass the locking mechanism and force a change to the underlying data. Also, if a lock is based on a session, a user may have multiple tabs open in the same browser, using the same session state (via shared cookies)

Solving this problem will reduce the chance that when a person does post a form update, that there is any conflict, meaning fewer tears.

3) Not knowing that data has changed on the server until you submit a form.

Ideally there would be a means for someone viewing/editing a form to immediately be notified if data changes on the server, obsoleting the current form. This reduces the amount of wasted time is spent completing a form which is already known to be out of sync, and will need to be redone anyway (as long as problem 1 is solved; otherwise, there'll be data loss)

4) Smarter form validation

There are three types of missing validation: 
- the first is that the default widgets do not support even very simple client-side validation. For example, a text field might need to match a regular expression. 
- the second type is an ability to provide (in the model definition) arbitrary javascript which can be executed client-side to provide richer realtime validation during data entry.
- the third type involves effectively providing provisional form data to the server, and having Django validate() the form content without actually saving the result. This would allow (for example) inter-field dependencies to be evaluated without any custom code, providing near-realtime feedback to the user that their form is invalid


The solutions
This is based on a day or so's experimentation, and I very much welcome any feedback, both in terms of the usefulness of solving these problems in general, as well as suggestion on better ways to solve the problems,  before I go too far down any rabbit holes.

Enhanced forms
- when rendering a form (using e.g. as_p()), alongside the normal INPUT DOM elements, include additional hidden fields which store a copy of each form field's initial value. 
- when a form is submitted, compare these hidden values against the current value in the database. If any of these do not match, the clean() method can raise a ValidationError, allowing the user to know what has happened, and that they will need to reload the form and try again, with the new stored values.

This solution is minimally invasive. As well as modifying as_p() and friends, a django template tag can also be exposed for those users who are rendering their forms in a different way.
Note that there is no reliance on additional attributed in the models: the CAS-like checking performed is explicitly on the rendered form fields; it does not matter if other model fields' values have changed, as someone editing the form can neither see these field values, nor will their POSTing modify these other fields' values.
(I have implemented the above already, for generic model forms using a single model)

Locking
- provide a mixin which can be used on selected models. When used, a view (usually some sort of form view) can attempt to lock() the model. If successful (because it's not currently locked to someone else), only they can perform writes to the model, until the lock expires. 
- if the lock has expired, anyone (including the user who took out an expired lock) may overate on the model instance.
- the lock can be configured to either use the standard database ORM, or redis. Redis will be more performant, but should not be a hard requirement
- there will be pain points associated with using this without the websocket solution, detailed below: there will not be a clean way to maintain the lock, if the time between consecutive requests is greater than the timeout value

Websocket
- provide a model mixin to enable websocket monitoring
- use Django Channels to expose a websocket consumer
- provide a templatetag which will include appropriate javascript into a web page to initialise the client connection (if any forms are configured to be monitored)
- when the client initialises, it detects the form fields (as per the 'Enhanced Forms' solution) and registers the model instance(s) with the server, via the websocket.
- whenever a monitored instance changes in Django, a signal is raised, pushing notifications to any clients, along with the new values
- the client can immediately compare the new instance values to the original values on the form (stored in the hidden fields) and can update the widgets directly if required (e.g. setting a CSS class to indicate the input is invalid, and updating the validation message shown alongside that.


A final aspect of the solution is the javascript widgets, but I feel my post is already about 5 times too long.

Any thoughts/comments are welcome.

Thanks.

--
You received this message because you are subscribed to the Google Groups "Django users" group.
To unsubscribe from this group and stop receiving emails from it, send an email to django-users+unsubscribe@googlegroups.com.
To view this discussion on the web visit https://groups.google.com/d/msgid/django-users/e9d6ee80-19d2-4ca2-aa1b-10daf7217182n%40googlegroups.com.

No comments:

Post a Comment