#59 Optimistic Locking (revised)
- Download:
- source codeProject Files in Zip (97.2 KB)
- mp4Full Size H.264 Video (15.4 MB)
- m4vSmaller H.264 Video (8.71 MB)
- webmFull Size VP8 Video (11.4 MB)
- ogvFull Size Theora Video (18.6 MB)
If we have a busy application whose records are being updated frequently there’s a good chance that a user might unintentionally overwrite someone else’s changes. Below is a products listing page from a simple e-commerce site which has a number of admin users who can update each product’s details.
Let’s say that we need to change the price of one of the products. We open its edit form but while we’re looking up the updated price another user changes the same product’s name and submits their changes. When we then find the price and submit our changes our product’s name overwrites the changes that the other user made.
Optimistic Locking
Optimistic locking is a common solution to this kind of problem so we’ll add this to our application. We need to add a new column to the database table that we want to add locking to so we’ll start by generating a new migration. The column we add has to be called lock_version
as doing so means that ActiveRecord picks this column up and automatically uses it for optimistic locking.
$ rails g migration add_lock_version_to_products lock_version:integer
The column needs to be an integer and should have a default value of zero. Every time the row is updated this value will then be incremented and when an update is submitted whose lock_version
doesn’t match the value in the database it will be rejected. It’s important that the column has a default value of zero so we’ll modify the migration to set this before we run it.
class AddLockVersionToProducts < ActiveRecord::Migration def change add_column :products, :lock_version, :integer, default: 0, null: false end end
Now we can run rake db:migrate
to add the column.
Next we’ll add a hidden field to the product’s form view to hold the lock_version
.
<%= f.hidden_field :lock_version %>
Our application now knows the version of the product that is being updated. We also need to modify the Product
model file so that the field can be updated through mass assignment.
class Product < ActiveRecord::Base belongs_to :category attr_accessible :name, :price, :released_on, :category_id, :lock_version end
When we edit a product now the form has an extra hidden field containing the current lock_version
. If we try to update a product now and another user makes changes between us opening the edit page and submitting the form a StaleObjectError
exception will be raised as when the other user made their changes the lock_version
for the product will have been updated which doesn’t match the lock_version
value in our form.
Raising an exception doesn’t provide a good user experience and we should instead rescue
from this error and give the user a chance to fix the problem. One option is to handle this in the controller’s update
action. We could rescue from the exception there and render a conflict resolution view that displays both versions of the product and allows the user to resolve the conflicting fields. This means that we’d need to write a new view template and abstracting this functionality to work with all of our application’s resources could be tricky.
Instead we’ll handle this through validations so that the errors are automatically displayed when the edit form is rendered again. We’ll write a new method in the Product
model that we’ll call update_with_conflict_validation
and use this in the controller. This method will take the arguments that are passed to it from the controller and use them to try to update the product, just like the normal update method would. The only difference is that if a StaleObjectError
is raised we add an error message to the list and return false
so that the update fails.
def update_with_conflict_validation(*args) update_attributes(*args) rescue ActiveRecord::StaleObjectError errors.add :base, "This record changed while you were editing it." false end
We’ll use this method in our controller.
def update @product = Product.find(params[:id]) if @product.update_with_conflict_validation(params[:product]) redirect_to @product, notice: "Updated product." else render :edit end end
When we submit the form now we’ll see a validation error instead of an exception.
While this is an improvement the user isn’t told which fields changed or what their values were. We can add this by using Dirty Tracking which was covered in episode 109. If we call changes on a model this will return a hash of the changed attributes. We can add an error to the list for each changed attribute like this:
def update_with_conflict_validation(*args) update_attributes(*args) rescue ActiveRecord::StaleObjectError errors.add :base, "This record changed while you were editing it." changes.each do |name, values| errors.add name, "was #{values.first}" end false end
When we reload the page now the user can see exactly which attributes are different from those in the database. In this case it means that they’ll now know that the name was changed while they were making their changes.
It would be better if some of the attributes didn’t show up. The lock_version
and updated_at
fields are easy to hide, but the price
is something that the user is changing intentionally. We don’t have enough information about the previous versions of this product to know that the conflicting change didn’t change the price of this product. This is something it’s possible to do but it isn’t a trivial change and it’s not something we’ll be covering here. Another issue we have is that because the lock version stays the same submitting the form again won’t allow us to update the product even after we’ve resolved the conflicts. To fix these issues we’ll set the lock_version
to be the same as the version in the database which we can do by using the lock_version_was
method. We’ll also hide the updated_at
field from the list of errors.
def update_with_conflict_validation(*args) update_attributes(*args) rescue ActiveRecord::StaleObjectError self.lock_version = lock_version_was errors.add :base, "This record changed while you were editing it." changes.except("updated_at").each do |name, values| errors.add name, "was #{values.first}" end false end
When the user runs into a conflict now only errors that pertain to the form fields are shown and the user can make the necessary changes and then submit them.
An Alternative Approach
Our solution is now pretty much complete but there are a couple of issues with it. One is that we’re adding validations outside of the ActiveRecord validation step. This generally works but there could be side-effects in certain situations. Also if there are other columns that are frequently updated but which aren’t displayed on the form these will update the lock_version
and will trigger a conflict, even though there may not be a conflict in the fields that the user is editing. There’s no easy way to exclude attributes from updating the lock_version
counter but then there shouldn’t be as this isn’t the way that optimistic locking is meant to work.
What we do in this form pushed the boundaries of what optimistic locking is designed to do so we’ll try an alternative solution that doesn’t use optimistic locking. We’ve rolled back the changes we’ve made to our application so that we’re back where we started. We only need to change two files to implement this alternative solution and we don’t even need to generate a migration. First we’ll add a hidden field to the form like we did before. This will be called original_updated_at and will contain the updated_at time value for the product at the time the form was displayed.
<%= f.hidden_field :original_updated_at %>
The other changes we’ll make will be in the Product
model.
class Product < ActiveRecord::Base belongs_to :category attr_accessible :name, :price, :released_on, :category_id, :original_updated_at validate :handle_conflict, only: :update def original_updated_at @original_updated_at || updated_at.to_f end attr_writer :original_updated_at def handle_conflict if @conflict || updated_at.to_f > original_updated_at.to_f @conflict = true @original_updated_at = nil errors.add :base, "This record changed while you were editing. Take these changes into account and submit it again." changes.each do |attribute, values| errors.add attribute, "was #{values.first}" end end end end
We’ve added the original_updated_at
field to the list in attr_accessible
so that it can be set through mass assignment. This is a virtual attribute with a getter method and a setter and the getter defaults to the current updated_at time converted to a float so that it’s more precise in the form. We’ve also added a validation called handle_conflict
which is fired when the record is updated. This method checks to see if the updated_at
attribute in the database is later than the one on the hidden form field. If it is then there’s a conflict and so a number of validation errors will be added much like we had before.
This functionality works much like it did before. If two updates are made at the same time a validation error is displayed and the fields with potentially conflicting values are listed along with their current values in the database. What’s better about this solution is that the validations are in the right place and that we haven’t had to make any changes to the database as we’re using the updated_at
field. If we wanted to use a new field so that we could add conflicts for only certain form fields we could add another time-based column to the database and use that instead.
We’ve tried to keep this solution as generic as possible so it should work with multiple models. This means that we could abstract this functionality out and include it in each model that we want to handle conflicts in.