ruby

Optimistic Locking in Rails REST APIs

Karol Galanciak

Karol Galanciak on

Optimistic Locking in Rails REST APIs

Imagine the following hypothetical scenario: in a rental property management system, Employee A starts editing contact info for Rental X, adding some extra phone numbers. Around the same time, Employee B notices a typo in the contact info for exactly that Rental X and performs an update. A couple of minutes later, Employee A updates Rental X's contact info with the new phone numbers, and ... the update fixing the typo is now gone!

That's definitely not great! And this is a pretty trivial scenario. Imagine a similar conflict happening in a financial system!

Could we avoid such scenarios in the future? Fortunately, the answer is yes! We need concurrency protection and locking — specifically, optimistic locking — to prevent such problems.

Let's explore optimistic locking in Rails REST APIs.

'Lost Updates' and Optimistic Locking vs. Pessimistic Locking

The scenario we've just gone through is a type of 'Lost Update'. When two concurrent transactions update the same column of the same row, the second one will override the changes from the first one, essentially as if the first transaction never happened.

Usually, this problem can be addressed by:

  • Setting a proper Transaction Isolation level, which handles the problem on the database level.
  • Pessimistic locking — preventing concurrent transactions from updating the same row. The second transaction waits for the first transaction to finish before it even reads the data. The great advantage here is that it is impossible to operate on stale data. The major disadvantage, though, is that it also blocks reading the data from a given row.
  • Optimistic locking — stops the modification of the given row if its state at the time of modification is different from when it was read.

Our problem is not about concurrent database transactions (more like business transactions) — so the first solution is not really applicable. This means we're left with pessimistic locking and optimistic locking.

Pessimistic locking would prevent the lost update from happening in our hypothetical scenario in the first place. However, it would also make life difficult for users if it blocked access to data for a very long time (imagine it reading and editing some fields for 30 minutes or more).

Optimistic locking would be far less restrictive, as it would allow multiple users to access data. However, if several users start editing the data concurrently, only one can perform the operation. The rest would see an error stating that they operated on stale data and need to retry. Not ideal, but with proper UX, this might not necessarily be that painful.

Let's see how we could implement optimistic locking in a hypothetical Rails REST API.

Optimistic Locking in REST APIs

Before we get to implementation in the actual Rails app, let's think about what optimistic locking could look like in the context of general REST APIs.

As described above, we need to track an object's original state when reading it to compare against its later state during the update. If the state doesn't change since the last read, the operation is allowed. If it has changed, though, it will fail.

What we need to figure out in the context of REST APIs is:

  • When reading the data of a given resource, how do we express the current state of an object and return it in a response for a consumer?
  • How should consumers propagate the original state of a resource to an API when performing the update?
  • What should the API return to the consumer if the state has changed and the update is not possible?

The great news is that all these questions can be answered and handled with HTTP semantics.

As far as tracking the state of a resource goes, we can take advantage of Entity Tags (or ETags). We can return the resource's fingerprint/checksum/version number in the dedicated ETag header to API consumers to send later with the PATCH request. We can use an If-Match header, making it pretty straightforward for the API server to check if the resource has changed or not. It is just a case of comparing the checksums/version number/whatever else you choose as the ETag.

The request will succeed if the current ETag and If-Match values are the same. If not, the API should respond with the rarely-used 412 Precondition Failed status, the most appropriate and expressive status that we can use for this purpose.

There is one other possible scenario. We can only compare the ETags if the API consumer provides the If-Match header. What if it doesn't? You could ignore concurrency protection and forget about optimistic locking, but that might not be ideal. One other solution would be to make it a requirement to provide the If-Match header and respond with 428 Precondition Required status if it's not.

Now that we have a solid overview of how optimistic locking could work in REST APIs, let's implement it in Rails.

Optimistic Locking in Rails

The great news is that Rails offers optimistic locking out-of-the-box — we can use the feature provided by ActiveRecord::Locking::Optimistic. When you add the lock_version column (or whatever else you want, although that requires additional declarations on the model level to define the locking column), ActiveRecord will increment it after each change and check if the currently assigned version is the expected one. If it's stale, the ActiveRecord::StaleObjectError exception will be raised on the update/destroy attempt.

The easiest way to handle optimistic locking in our API is to use the value from lock_version as an ETag. Let's do this as the first step in our hypothetical RentalsController:

1class RentalsController
2  after_action :assign_etag, only: [:show]
3
4  def show
5    @rental = Rental.find(params[:id])
6    respond_with @rental
7  end
8
9  private
10
11  def assign_etag
12    response.headers["ETag"] = @rental.lock_version
13  end
14end

This is, of course, a very simplified version of the controller as we are only interested in whatever is required for optimistic locking, not authentication, authorization, or other concepts. This is enough to expose the proper ETag to the consumer. Let's now take care of the If-Match header that the consumers can provide:

1class RentalsController
2  after_action :assign_etag, only: [:show, :update]
3
4  def show
5    @rental = Rental.find(params[:id])
6    respond_with @rental
7  end
8
9  def update
10    @rental = Rental.find(params[:id])
11    @rental.update(rental_params)
12    respond_with @rental
13  end
14
15  private
16
17  def assign_etag
18    response.headers["ETag"] = @rental.lock_version
19  end
20
21  def rental_params
22    params
23      .require(:rental)
24      .permit(:some, :permitted, :attributes).merge(lock_version: lock_version_from_if_match_header)
25  end
26
27  def lock_version_from_if_match_header
28    request.headers["If-Match"].to_i
29  end
30end

And that's actually enough to have the minimal version of optimistic locking working! Although, clearly, we don't want to return 500 responses if there is any conflict. We will make If-Match required for any update too:

1class RentalsController
2  before_action :ensure_if_match_header_provided, only: [:update]
3  after_action :assign_etag, only: [:show, :update]
4
5  rescue_from ActiveRecord::StaleObjectError do
6    head 412
7  end
8
9  def show
10    @rental = Rental.find(params[:id])
11    respond_with @rental
12  end
13
14  def update
15    @rental = Rental.find(params[:id])
16    @rental.update(rental_params)
17    respond_with @rental
18  end
19
20  private
21
22  def ensure_if_match_header_provided
23     request.headers["If-Match"].present? or head 428 and return
24  end
25
26  def assign_etag
27    response.headers["ETag"] = @rental.lock_version
28  end
29
30  def rental_params
31    params
32      .require(:rental)
33      .permit(:some, :permitted, :attributes)
34      .merge(lock_version: lock_version_from_if_match_header)
35  end
36
37  def lock_version_from_if_match_header
38    request.headers["If-Match"].to_i
39  end
40end

And that's pretty much everything required to implement all the functionality that we discussed earlier. We could improve way more things — e.g., by providing some extra error messages besides just the response code — but that would be outside the scope of this article.

Wrap-up: The Importance of Optimistic Locking in Rails APIs

Concurrency protection is often overlooked when designing REST APIs, which can lead to severe consequences.

Nevertheless, implementing optimistic locking in Rails APIs is pretty straightforward — as demonstrated in this article — and will help avoid potentially critical issues.

Have fun coding!

P.S. If you'd like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!

Share this article

RSS
Karol Galanciak

Karol Galanciak

Our guest author Karol Galanciak is a Distributed Systems Architect, Ruby on Rails expert, and CTO at BookingSync. Outside of software development, he's a bachata dancer, guitarist, scuba diver, and traveler.

All articles by Karol Galanciak

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps