Rails 6 adds Relation#create_or_find_by


Rails 6 has added ActiveRecord::Base.create_or_find_by/! as an alternative to ActiveRecord::Base.find_or_create_by/!

find_or_create_by

Relation#find_or_create_by is one of Rails finder methods, that finds the first record with the given attributes, or creates a record with the attributes if one is not found.

# Find the first company with name "Saeloun" 
# or create a new one
Company.find_or_create_by(name: 'Saeloun')
# => #<Company id: 1, name: "Saeloun">

# Second time we call the same, 
# it will return existing record, 
# since and existing record exists
Company.find_or_create_by(name: 'Saeloun')
# => #<Company id: 1, name: "Saeloun">

One of the problems of this approach is that its not an atomic operation. It first runs a SELECT, and if there are no results an INSERT is attempted.

In high scale applications, this can cause race conditions due to stale reads. Separate threads might attempt to first SELECT and then end up INSERT ing multiple records.

Overcoming duplicate inserts

One way of still overcoming this race condition is catching duplicate record errors. These errors are thrown only if there is an underlying unique constraint on a field.

# name has a unique constraint in companies table 
begin
  Company.transaction(requires_new: true) do
    Company.find_or_create_by(name: 'Saeloun')
  end
rescue ActiveRecord::RecordNotUnique
  retry
end
# => #<Company id: 1, name: "Saeloun">

In above scenario, it attempts an insert, if a race condition is hit, an ActiveRecord::RecordNotUnique is thrown. We can simply rescue and retry again, to fetch the existing record.

create_or_find_by

Enter create_or_find_by.

# Create a new company with name "Saeloun" 
# or return existing one
Company.create_or_find_by(name: 'Saeloun')
# => #<Company id: 1, name: "Saeloun">

create_or_find_by tries to create a new record with the given attributes,
that has a unique constraint on one or several of its columns.

As in our example above, if a record already exists with one of these unique constraints, an exception raised is first caught.

It then proceeds to use find_by! and returns the record.

This helps use to overcome the stale reads issue which is caused by race conditions.

Limitations

create_or_find_by does have its own limitations even though it overcomes stale reads issue.

We can still be hit by a race condition of INSERT -> DELETE -> SELECT, which will end up with no result found. This is a much rarer scenario than the SELECT -> INSERT situation.

This only works if we have unique constraints on all columns we are using to create or find by. If we don’t, it will not raise ActiveRecord::RecordNotUnique, and simply insert a duplicate record.

Since all this mechanism relies on throwing and catching exceptions, it can tend to be relatively slower.