Guaranteeing uniqueness and persistence with ActiveRecord
Generally, when someone wants to ensure that a models field is unique, they use a database unique index in conjunction with validates_uniqueness_of :field
.
The problem with this approach is that while the validation rule will hit the database checking if another record with the given value for the given column exists, and if not, it tries to save the record.
So why won’t it work?
As documented in the Rails source, this approach is error prone, especially for race-conditions (see here for more information on this).
Basically what happens is that record one checks if the field value “John” for the field :name already exists in the table, if not, it calls save. Now a second record with the same value “John” is created at the same time, which also concludes that there is no existing record with that value in the database, and proceeds to save.
As both record pass validations and call save, one of them actually gets saved, and the other one gets blown off with a icky ActiveRecord::StatementInvalid
exception due to the database unique index constraints being violated.
Isn’t this acceptable?
In most cases – yes. But I have an Order object that is lazily initialized i.e the user clicks “Add to cart”, applications checks if a record for this user already exists, if not it creates one and proceeds to adding stuff into the cart and displaying the shopping cart page. And every order must get an unique token during the creation process.
It would be unacceptable to display an error in case of a race condition to the user during this process. I need to have a guarantee that the Order gets persisted to the database with an unique token.
But it can’t be guaranteed!
Technically no. But I can get close enough. I just need to recover from the unique database constraint error and retry with another value.
To avoid the database being locked up by several processes trying and retrying to generate and save new record with unique tokens (and possibly failing due to some other error instead of the unique index one), i define a reasonable retry count.
Then I can assume that if the record could not be saved within that number of retries, it’s either broken for some other reason (or we’re dealing with an unusually high amount of token uniqueness clashes).
after_create :set_token
protected
def set_token
begin
self.transaction do
update_attribute(:token, Digest::MD5.hexdigest(Array.new(10){rand(9)}.join))
end
rescue ActiveRecord::StatementInvalid => error
@token_retry_count = (@token_retry_count || 0) + 1
if @token_retry_count < 10
set_order_token
else
raise error
end
end
end
end
Seems fragile…
But it works. Unless you reach the limit of unique combinations for your unique field. That’s why the exception is re-raised on unsuccessful retries – you can crawl the logs and react when something seems really broken. Until then it’s good enough, and most importantly it’s much more reliable than the validates_uniqueness_of :token
, providing a better user experience.
Also, if you have any suggestions or questions, or great ideas how to achieve guaranteed uniqueness with error recovery and (almost) guaranteed persistence, do not hesitate to leave a comment.
1 Comment