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!