ActiveRecord Optimistic Locking

A basic guide to preventing overwriting a record with old data.

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

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:

  1. 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.
  2. The Marketing team is notified of new products and starts working on better product descriptions, and setting the prices
  3. 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.

Error Message Displayed on form.png

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:

I hope this has given you some insight and inspiration into how you could use locking in your code.

🚀

Did you find this article valuable?

Support Unathi Chonco by becoming a sponsor. Any amount is appreciated!