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 MIT License.



Date
July 14, 2023