Use create_or_find_by to avoid race condition in Rails 6.0
It’s pretty common in Ruby on Rails to use find_or_create_by
method when you want to create a new record if the record isn’t already existed:
> User.find_or_create_by(username: "sikachu")
User Load (0.2ms) SELECT "users".* FROM "users" WHERE "users"."username" = ? LIMIT ? [["username", "sikachu"], ["LIMIT", 1]]
(0.1ms) begin transaction
User Create (1.9ms) INSERT INTO "users" ("username", "created_at", "updated_at") VALUES (?, ?, ?) [["username", "sikachu"], ["created_at", "2018-02-21 02:52:05.983257"], ["updated_at", "2018-02-21 02:52:05.983257"]]
(0.8ms) commit transaction
#=> #<User id: 1, username: "sikachu", created_at: "2018-02-21 02:52:05", updated_at: "2018-02-21 02:52:05">> User.find_or_create_by(username: "sikachu")
User Load (0.2ms) SELECT "users".* FROM "users" WHERE "users"."username" = ? LIMIT ? [["username", "sikachu"], ["LIMIT", 1]]
#=> #<User id: 1, username: "sikachu", created_at: "2018-02-21 02:52:05", updated_at: "2018-02-21 02:52:05">
However, this method is actually prone to a race condition. It’s possible that user may double-click a submit button and duplicate a HTTP request. If the timing is perfect, bothSELECT
would then see no records, then try to send INSERT
with the same values. In the end, you will end up with either two rows with the same value in the database, or user will face an Internal Server Error
page.
To prevent this type of race condition, DHH recently submitted a patch to add a new method call create_or_find_by
to offer a safer way to create a new record in the database if the given condition isn’t already exists. This method avoids the race condition by using database unique key constraint error to control the flow instead of the traditional SELECT-then-INSERT (which is the behavior of find_or_create_by
).
Because this new create_or_find_by
method has the same method signature as find_or_create_by
, you can just substitute it right away:
# Similar usage as the code above:
User.create_or_find_by(username: "sikachu")# There is also a bang version which will raise if there is
# a validation error:
User.create_or_find_by!(username: "sikachu")
Internally, this method does a create
, rescueActiveRecord::RecordNotUnique
exception, and perform find_by!
if that exception got raised. While this is totally using an exception as a flow control (which is discouraged), I think this scenario is an acceptable use as you are letting your database backend which stores your data to maintain your data consistency.