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