Thursday, August 9, 2007

Optimistic Locking in Rails with Active Record for Free...almost

Like other persistence frameworks, ActiveRecord helps us with concurrency issues. This is actually fairly simple, but in general the documentation I have found on this subject has been poor. I will try to sum this up very quickly.

1) Add lock_version

For each table where concurrency is a concern a lock_version column must be added; Rails is then supposed to perform some magic behind the scenes...

In your migration this should look as follows.
table_name.column :lock_version, :integer, :default=>0

Alternatively, if you already have a versioning column in your database you can inform rails of your column by adding the line "set_locking_column 'your_versioning_column_name'" to the associated model class.

2) Catch StaleObjectError

Now that you have added the lock_version column you get concurrency for free. Whenever an attempt occurs to save over the same record version an ActiveRecord::StaleObjectError will be raised.

So now you can update your controllers to catch StaleObjectErrors and handle these however you wish...

This would look something like this.

def some_updating_method(an_object)
an_object.update_attributes(params[:an_object])
rescue ActiveRecord::StaleObjectError => e
flash[:notice] = "This record changed while you were editing it. Please try again."
#maybe perform a redirect here...
end

3) But Wait! Update methods aren't actually passed objects that's too much overhead!

How observant you are. This is where most of the example tend to fall short. In fact, try implementing your update method exactly like Recipe 3.18 from the Rails Cookbook and see what happens...

Generally an update method actually looks more like this.

def update
my_object = MyObject.find(params[id])
my_object.update_attributes(params[:my_object])
end

While this will stop to users from updating at the exact same time this isn't exactly what we want in many cases. More often than not we want to prevent two users from editing over each other without knowing.

This is easily fixed with the following...

def edit
my_object = MyObject.find(params[id])
session[lock_version] = my_object.lock_version
# whatever we want edit to do
# ...
# ...
# call update...
end

Now we update update (har har) to detect the concurrency issue...

def update
if my_object.lock_version != session[:lock_version]
raise ActiveRecord::StaleObjectError
end
my_object = MyObject.find(params[id])
my_object.update_attributes(params[:my_object])
rescue ActiveRecord::StaleObjectError => e
flash[:notice] = "This record changed while you were editing it. Please try again."
#maybe perform a redirect here...
end

Hopefully this helps someone out. If you have a better/cleaner way of doing this, let me know.

2 comments:

Unknown said...

You can add a hidden lock_version field in the edit view that way when submitted ActiveRecord will see that the object is stale.

Richie Vos said...

Good idea. Really love Steven's idea though. Will have to give that a try.