SaveIf concern for Rails optimistic locking without lock_version
I wrote a proposal for Rails to implement an optimistic locking feature that doesn’t require lock_version
column.
Forum post
There are some use cases where optimistic locking is useful, but doesn’t need the full complexity of a lock_version
increment. For example, say a record in the database needs to be “claimed” by the first user who claims it.
| id | user_id |
|----|---------|
| 1 | NULL |
If I have 2 requests in parallel,
- Request 1:
Record.find(1).update(user_id: 111)
- Request 2:
Record.find(1).update(user_id: 222)
And if both objects are instantiated while user_id
is still NULL
, then last one will always “win”, even if user_id was no longer null at the moment of commit.
So even if first one wrote 111
, the second would change it to 222
.
I could use either pessimistic or optimistic locking for this. In my case optimistic makes more sense from performance standpoint. However, lock_version
seems like an overkill, since all I’m really doing is:
UPDATE records SET user_id = 222 WHERE id = 1 AND user_id is NULL
Then raising an error if affected_rows.zero?
.
I could technically do this using Record.update_all
, but then I’m losing all the callbacks/validations/etc.
Proposal
What if we had something like update_if
or save_if
function that accepted the constraints hash?
account.update_if({balance: 400}, balance: 500)
and/or
account.balance = 500
account.save_if(balance: 400)
or
account.update(balance: 500).where(balance: 400)
This would support all the callback/validation stuff rails has, maybe even accept standard save **options
, but also raise ActiveRecord::StaleObjectError
if original balance wasn’t 400.
The exact interface of this feature is definitely up for debate. What do ya’ll think? Or is there something obvious I’m missing that does this already, while preserving callbacks?
P.S. And the existing lock_version
code could then be rewritten on top of this feature, since the behavior is largely the same.
SaveIf Concern
Whether this proposal makes it into the Rails core or not, I wrote a SaveIf
concern that works as of Rails 7.0.6, Ruby 3.2, and adds this feature. (See GemPatch: monkeypatch rubygems responsibly for GemPatch
feature.)
GemPatch.require 'rails', '7.0.6', details: <<-TEXT
Reasons to review this patch:
1. This patch uses some private methods of Rails. While it's covered with
tests, it's still a good idea to review the code with each Rails version
bump, in case tests missed something. The relevant code is in
- ActiveRecord::Persistence
https://github.com/rails/rails/blob/main/activerecord/lib/active_record/persistence.rb
- ActiveRecord::Optimistic
https://github.com/rails/rails/blob/main/activerecord/lib/active_record/locking/optimistic.rb
- ActiveRecord::Transactions
https://github.com/rails/rails/blob/main/activerecord/lib/active_record/transactions.rb
2. There is a proposal to add this feature to Rails:
https://discuss.rubyonrails.org/t/proposal-optimistic-locking-without-lock-version/83221.
If the proposal is accepted (or the conversation reveals a better
solution), we should remove the patch in favor of that.
TEXT
# This module is similar to Rails Optimistic Locking, but without the need to
# create a special column. This lets you specify what fields the record should
# have had prior to update. If fields in the database don't have the original
# values you're expecting, update will not go through.
#
# Example:
#
# account = Account.create(balance: 1000)
#
# account.balance = 2000
# account.save_if(balance: 1000) # => true
# account.save_if(balance: 1000) # => false
# account.save_if!(balance: 1000)
# => ActiveRecord::StaleObjectError
#
# account.update_if({ balance: 2000 }, balance: 3000) # => true
# account.update_if({ balance: 2000 }, balance: 3000) # => false
# account.update_if!({ balance: 2000 }, balance: 3000)
# => ActiveRecord::StaleObjectError
module SaveIf
def update_if!(constraints, attributes)
if destroyed?
raise ActiveRecord::ActiveRecordError, 'cannot update a destroyed record'
end
update_if(constraints, attributes) ||
raise(ActiveRecord::StaleObjectError.new(self, 'update'))
end
def update_if(constraints, attributes)
save_if(constraints) { assign_attributes(attributes) }
end
def save_if!(constraints)
if destroyed?
raise ActiveRecord::ActiveRecordError, 'cannot update a destroyed record'
end
save_if(constraints) ||
raise(ActiveRecord::StaleObjectError.new(self, 'update'))
end
def save_if(constraints)
if new_record?
raise ActiveRecord::ActiveRecordError, 'cannot update a new record'
end
_raise_readonly_record_error if readonly?
return false if destroyed?
@_save_if_constraints = constraints.stringify_keys
with_transaction_returning_status do
yield if block_given?
_update_record == 1
end
ensure
@_save_if_constraints = nil
end
private
def _query_constraints_hash
return super unless @_save_if_constraints
@_save_if_constraints.merge(super)
end
end
Tests
See Helper for creating table and model in Rails tests for make_test_model
snippet.
require 'test_helper'
class SaveIfTest < ActiveSupport::TestCase
setup do
@model, @teardown = make_test_model(id: :uuid) { |t|
t.string :field
t.timestamps
}
@model.include(SaveIf)
end
teardown { @teardown.call }
test '#save_if succeeds when constraints match' do
record = @model.create!(field: 'old value')
result = assert_changes -> { record.reload.updated_at } do
record.field = 'new value'
record.save_if(field: 'old value')
end
assert result
assert_equal 'new value', record.field
end
test '#save_if fails when constraints dont match' do
record = @model.create!(field: 'old value')
result = assert_no_changes -> { record.reload.updated_at } do
record.field = 'new value'
record.save_if(field: 'wrong value')
end
refute result
assert_equal 'old value', record.field
end
test '#save_if fails when record is destroyed' do
record = @model.create!(field: 'old value')
record.field = 'new value'
record.destroy
refute record.save_if(field: 'old value')
end
test '#save_if errors out when record is new' do
record = @model.new(field: 'old value')
assert_raises(ActiveRecord::ActiveRecordError) { record.save_if({}) }
end
test '#save_if errors out when record is readonly' do
record = @model.create!(field: 'old value')
record.readonly!
assert_raises(ActiveRecord::ActiveRecordError) { record.save_if({}) }
end
test '#save_if! errors out when constraints dont match' do
record = @model.create!(field: 'old value')
record.field = 'new value'
assert_raises(ActiveRecord::StaleObjectError) do
record.save_if!(field: 'wrong value')
end
end
test '#save_if! errors out when record is destroyed' do
record = @model.create!(field: 'old value')
record.field = 'new value'
record.destroy
assert_raises(ActiveRecord::ActiveRecordError) do
record.save_if!(field: 'old value')
end
end
test '#update_if succeeds when constraints match' do
record = @model.create!(field: 'old value')
result = assert_changes -> { record.reload.updated_at } do
record.update_if({field: 'old value'}, field: 'new value')
end
assert result
assert_equal 'new value', record.field
end
test '#update_if fails when constraints dont match' do
record = @model.create!(field: 'old value')
result = assert_no_changes -> { record.reload.updated_at } do
record.update_if({field: 'wrong value'}, field: 'new value')
end
refute result
assert_equal 'old value', record.field
end
test '#update_if errors out when record is new' do
record = @model.new(field: 'old value')
assert_raises(ActiveRecord::ActiveRecordError) { record.update_if({}, {}) }
end
test '#update_if errors out when record is readonly' do
record = @model.create!(field: 'old value')
record.readonly!
assert_raises(ActiveRecord::ActiveRecordError) { record.update_if({}, {}) }
end
test '#update_if! errors out when constraints dont match' do
record = @model.create!(field: 'old value')
assert_raises(ActiveRecord::StaleObjectError) do
record.update_if!({field: 'wrong value'}, field: 'new value')
end
end
test '#update_if! errors out when record is destroyed' do
record = @model.create!(field: 'old value')
record.destroy
error = assert_raises(ActiveRecord::ActiveRecordError) do
record.update_if!({ field: 'old value' }, field: 'new_value')
end
assert_match /destroyed/, error.message
end
end
Code snippets in this post are covered by 0BSD License.