ActiveRecord Optimistic Locking and Counter Caching
In this post, we will discuss optimistic locking and how it can help us when building applications. This guide is targeted at Ruby on Rails developers, but similar strategies can be applied in other frameworks.
What is Optimistic Locking
Optimistic locking is a strategy used to verify that a database record has not been changed since the last time you read/fetched it. Locking gives you some certainty that any updates you are making are based on the most recent version.
So how do we use this, and how could it help ?
TL;DR: A Quick Practical Guide
- Documentation: https://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html
- Add
lock_version
column to your model - Include
lock_version
as hidden input in form - Handle
ActiveRecord::StaleObjectError
for when an update is attempted on an old version - Short example: https://gist.github.com/choncou/ed77efabdea92e38833839c974b35eee
The Longer Guide
Let's introduce our scenario.
We have a simple e-commerce application, with an assortment of products that are managed by different teams throughout the company. A common workflow in the company looks like this:
- The Inventory team adds new a product to the system as soon as they've received the stock. While they quickly import a lot of products, they prefer to only fill in the name and description.
- The Marketing team is notified of new products and starts working on better product descriptions, and setting the prices
- The Inventory team goes through all the new stock theyโve added, and update the packaged weight of each product.
Problem
The marketing team has started reporting that sometimes their updates to products are lost a while after saving and confirming the new information. How could this happen?
After some digging, we find out that when the inventory team adds a new product, they keep a tab open with the edit page for each product. When they press save on those old tabs, it will replace any other information for the product with what is on their form. Hereโs how it that looks in the application:
Our real problem is that we have no way of knowing if the information that somebody is submitting in the form contains outdated values which we don't actually want to override the existing data about a product.
The Solution
Optimistic locking was created to handle this kind of problem: allowing multiple users to access the same record for edits while assuming minimum conflict with the data.
Optimistic Locking achieves this by tracking a version number for a record. Every time that record is updated, the version number increases. If we try to update a record with a version number that doesn't match the current version in the database, Rails will throw an ActiveRecord::StaleObjectError
exception.
To add this to our existing application we need to:
1) Add a lock_version
integer column to our products table
By default, rails will use optimistic locking on your model when it finds the lock_version
column. It is also possible to specify a custom locking column name for a specific model by setting self.locking_column = :my_custom_column
(see documentation).
Our database schema now looks like this:
create_table "products", force: :cascade do |t|
t.string "name"
t.string "description"
t.integer "weight_grams"
t.integer "price_cents"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "lock_version"
end
2) Include lock_version
in our form submission
In the view where we render the form, we want to include a hidden form field with the version
<%= form.hidden_field :lock_version %>
In our controller, we need to permit the version column so that it is included in the params used to update the product.
# Only allow a list of trusted parameters through.
def product_params
params.require(:product).permit(:name, :description, :weight_grams, :price_cents, :lock_version)
end
โ
This is all we need to prevent the updates from happening. Now we get an exception when we try to make an update with the outdated form.
Handling ActiveRecord::StaleObjectError
The application crashing because the form is outdated isn't a great user experience, so the least we can do is let the user know what went wrong.
This isn't a post about error handling, so we'll go with the simplest solution of catching this error in the controller right where we are making the update. Right now, this is what our controllers #update
action looks like:
# PATCH/PUT /products/1 or /products/1.json
def update
respond_to do |format|
if @product.update(product_params)
format.html { redirect_to @product, notice: "Product was successfully updated." }
format.json { render :show, status: :ok, location: @product }
else
format.html { render :edit, status: :unprocessable_entity }
format.json { render json: @product.errors, status: :unprocessable_entity }
end
end
end
We can add an error message to the product and display it in the view. Our controller would then include a rescue
block to add the error to the product and display the edit page again.
# PATCH/PUT /products/1 or /products/1.json
def update
respond_to do |format|
if @product.update(product_params)
format.html { redirect_to @product, notice: "Product was successfully updated." }
format.json { render :show, status: :ok, location: @product }
else
format.html { render :edit, status: :unprocessable_entity }
format.json { render json: @product.errors, status: :unprocessable_entity }
end
rescue ActiveRecord::StaleObjectError => _error
@product.errors.add(:base, "Oops. Looks like the product has changed since you last opened it. Please refresh the page")
format.html { render :edit, status: :unprocessable_entity }
format.json { render json: @product.errors, status: :unprocessable_entity }
end
end
Now when we submit an outdated form, we are shown a regular error message. In your application, you could provide more context on the page, by showing the user both the new and old information.
Conclusion
Using optimistic locking in rails can give us a lot of value out of the box, but it is important to note that this doesn't solve all cases that could look the same. Optimistic locking should be used when there isn't a high chance of conflicts and race conditions. If you really need to prevent other users from editing a record until the first user is done, you should consider pessimistic locking or implementing something similar in your application.
Find the example application and implementation from this post here on GitHub:
https://github.com/choncou/blog-post-optimistic-locking/pull/1
I hope this has given you some insight and inspiration into how you could use locking in your code.
๐
Top comments (0)