Magicians never share their secrets. But we do. Sign up for our Ruby Magic email series and receive deep insights about garbage collection, memory allocation, concurrency and much more.
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.
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:
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.
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:
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
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.
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
1 2 3 4 5 6 7 8 9 10 11 12 13 14
class RentalsController after_action :assign_etag, only: [:show] def show @rental = Rental.find(params[:id]) respond_with @rental end private def assign_etag response.headers["ETag"] = @rental.lock_version end end
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
class RentalsController after_action :assign_etag, only: [:show, :update] def show @rental = Rental.find(params[:id]) respond_with @rental end def update @rental = Rental.find(params[:id]) @rental.update(rental_params) respond_with @rental end private def assign_etag response.headers["ETag"] = @rental.lock_version end def rental_params params .require(:rental) .permit(:some, :permitted, :attributes).merge(lock_version: lock_version_from_if_match_header) end def lock_version_from_if_match_header request.headers["If-Match"].to_i end end
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
class RentalsController before_action :ensure_if_match_header_provided, only: [:update] after_action :assign_etag, only: [:show, :update] rescue_from ActiveRecord::StaleObjectError do head 412 end def show @rental = Rental.find(params[:id]) respond_with @rental end def update @rental = Rental.find(params[:id]) @rental.update(rental_params) respond_with @rental end private def ensure_if_match_header_provided request.headers["If-Match"].present? or head 428 and return end def assign_etag response.headers["ETag"] = @rental.lock_version end def rental_params params .require(:rental) .permit(:some, :permitted, :attributes) .merge(lock_version: lock_version_from_if_match_header) end def lock_version_from_if_match_header request.headers["If-Match"].to_i end end
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.
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!
Our guest author Karol Galanciak is a Distributed Systems Architect, Ruby on Rails expert, and CTO at BookingSync. Besides software development, he’s a bachata dancer, guitarist, scuba diver, and traveler.