MyProblems::With::ActiveRecord
- What does
<<
actually do for ahas_many
relation - Use of transactions with
find_or_create_by
- Are you sure you exist?
- Racing to
find_or_create
records! - PSA:
save!
vs.save
NB: All lessons learned the hard way.
NB2: The AccountingService uses AR 4.2.4
- Lets say are creating a
foo
andbar
. foo
has manybars
in the rails way.
> foo.persisted?
#=> false
> bar.persisted?
#=> false
> foo.bars << bar # Does not hit the DB
#=> <ActiveRecord::Associations::CollectionProxy [<Bar id: nil quxx: nil>]>
> foo.save! # hits the DB! All records are persisted
#=> true
- Now
foo
andbar
need updating! - What happens when we run the same code!
> foo.persisted?
#=> true
> bar.persisted?
#=> true
> foo.bars << bar # HITS THE DB! Creates the association.
#=> <ActiveRecord::Associations::CollectionProxy [<Bar id: 1 quxx: nil>, <Bar id: 1 quxx: nil>]>
> foo.save! # The association was already created above.
#=> true
Be careful of this when using has_many
.
- Again,
foo
has_manybars
. - We want to build
bars
in a transaction and add them to aFoo
.
In the example below, if will create 5 new bars if 1 does not exist before transaction is created
Foo.transaction do
5.times do
bar_five = bar.find_or_create_by(quxx: 5) # created in transaction
foo.bars << bar_five # adds 5 bar_fives to foo
end
end
- This behavior is logical because the objects returned by
find_or_create_by
are unpersisted during the transaction. find_or_create_by
looks for a newbar
, finds nothing, and returns a new object.- It is important to understand that while in the transaction,
find_or_create_by
has no idea of your objects in memory.
But first, a tale from the Accounting Service...
- Double entry accounting means you are never creating just one ledger entry.
- Rows are inserted into the
ledger_entries
table in twos.
The code to do that looks something like this:
def self.persist_ledger_entries(le_one, le_two)
validate_transaction(le_one, le_two)
le_one.save!
le_two.save!
le_one.transaction_pair_id = le_two.id
le_two.transaction_pair_id = le_one.id
le_one.save!
le_two.save!
end
- Often times transactions trigger ledger entries across many different accounts
We provide an interface for this in the method create_transactions
:
# transactions is an array of ledger entry pairs
# example: [[entry1, entry2], [entry1, entry2]]
def self.create_transactions!(transactions)
LedgerEntry.transaction do
transactions.each do |transaction|
self.persist_ledger_entries(*transaction)
end
end
end
- The rollback properly cleans up the database, however our ruby objects are left in a partial state.
save!
callscreate_or_update
, so it looks like when you callsave!
twice in atransaction
block,ActiveRecord
does not refresh the whole object.
So that can lead to places like this:
foo = Foo.new
ActiveRecord::Base.transaction do
foo.save!
foo.save!
raise ActiveRecord::Rollback
end
foo.persisted?
#=> true
foo.id
#=> 1
foo.reload # Raises Error!
# ActiveRecord::RecordNotFound:\
# Couldn't find BusinessObject with 'id'=1
- Sinatra1 and Sinatra2 both hit MySql one after another and try to create identical Foo objects.
- Foo has a
validates_uniqueness_of
constraint onbuzz_type
, which Sinatra2 is violating. - calling
save!
on the Foo in Sinatra2 will raise aActiveRecord::RecordInvalid
error since the object in memory is now invalid.
Please note this method is not atomic, it runs first a SELECT, and if there are no results an INSERT is attempted. If there are other threads or processes there is a race condition between both calls and it could be the case that you end up with two similar records.
- Unclear behavior when
save
raises and when it returns false. save
will returnfalse
if an ActiveRecord validation fails. This indicates that the record was not persisted.save
will raise anActiveRecord::WrappedDatabaseException
if the record validates, but something at the DB level (such as a unique index) causes the save to fail.
The gripe here is it seems to violate the law of least suprise. The way people explain save
vs save!
is that one returns false
and the other raises. In truth they both can raise an error, its just one will raise due to issues on the ruby object or DB, where the other will just raise on DB issues.