Ruby: How to handle optimistic locking using updat

2019-08-31 04:11发布

I am trying to implement the Optimistic Locking for Race Condition. For that, I added an extra column lock_version in the Product: Model through migration.

#Product: Model's new field:
    #  attribute_1
    #  lock_version                       :integer(4)      default(0), not null
before_validation :method_1, :if => :recalculation_required_attribute

def method_1
    ####
    ####
    if self.lock_version == Product.find(self.id).lock_version
       Product.where(:id => self.id).update_all(attributes)
       self.attributes = attributes
       self.save!
    end
end

Product Model has an attribute_1. If recalculation is required for attribute_1 then before_validation: method_1 will call.

I am using optimistic locking using lock_version. However, update_all will not increase the lock_version. So I start usingsave!. Now I am getting a new error: SystemStackError: stack level too deep because self.save! triggers the before_validation: method1. How to stop infinite loop of call back and handle optimistic locking in the above case.

1条回答
▲ chillily
2楼-- · 2019-08-31 04:47

Possible Solution:

class Product < ApplicationRecord    
  before_validation :reload_and_apply_changes_if_stale, on: :update

  def reload_and_assign_changes_if_stale
    # if stale
    if lock_version != Post.find(id).lock_version
      # store the "changes" first into a backup variable
      current_changes = changes

      # reload this record from "real" up-to-date values from DB (because we already know that it's stale)
      reload
      # after reloading, `changes` now becomes `{}`, and is why we need the backup variable `current_changes` above

      # now finally, assign back again all the "changed" values
      current_changes.each do |attribute_name, change|
        change_from = change[0] # you can remove this line
        change_to = change[1]
        self[attribute_name] = change_to
      end
    end
  end
end

Important Notes:

  • the before_validation above STILL DOES NOT GUARANTEE that the race condition will be avoided! because see the example below:

    class Product < ApplicationRecord
      # this triggers first...
      before_validation :reload_and_apply_changes_if_stale, on: :update
      # then, this triggers next...
      before_update :do_some_heavy_loooong_calculation
    
      def do_some_heavy_loooong_calculation
        sleep(60.seconds)
        # ... of which during this time, this record might already be stale! as perhaps another "process" or another "server" has already updated this record!
      end
    
  • make sure that the before_validation above is at the very top of your Post model, so that that callback will be triggered first before any of your other before_validations (or even any subsequent callbacks: *_update, or *_save), as perhaps you might have one or two subsequent callbacks that are dependent on the current state of the attributes (i.e. it's doing some calculation, or checking against some boolean-flag attribute), which then you need to reload first (as is above), before doing these calculations.

  • the before_validation above will only work for "calculations/dependencies" in your model callbacks, but will not work properly if you have calculations/dependencies outside of your Product model's callbacks; i.e if you have something like:

    class ProductsController < ApplicationController
      def update
        @product = Product.find(params[:id])
    
        # let's assume at this line, @product.cost = nil (no value yet)
    
        @product.assign_attributes(product_attributes)
    
        # let's assume at this line, @product.cost = 1100
    
        # because 1100 > 1000, then DO SOME IMPORTANT THING!
        if @product.cost_was.nil? && @product.cost > 1_000.0
          # do some important thing!
        end
    
        # however, when `product.save` is called below and the `before_validation :reload_and_apply_changes_if_stale` is triggered,
        # of which let's say some other "process" has already updated this
        # exact same record, and thus @product is reloaded, but the real DB value is now
        # @product.cost = 900; there's no WAY TO UNDO SOME IMPORTANT THING! above
    
        @product.save
      end
    end
    

The notes above are why by default Rails do not auto-reload that attributes as a before_validation or something, because depending on your application/business logic, you might want to "reload" or "not-reload", and this is why by default Rails instead raises an ActiveRecord::StaleObjectError (see docs) for you to specifically rescue, and handle what to do accordingly if this race-condition happened.

查看更多
登录 后发表回答