MyProblems::With::ActiveRecord- What does
<<actually do for ahas_manyrelation - Use of transactions with
find_or_create_by - Are you sure you exist?
- Racing to
find_or_createrecords! - PSA:
save!vs.save
NB: All lessons learned the hard way.
NB2: The AccountingService uses AR 4.2.4
- Lets say are creating a
fooandbar. foohas manybarsin 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
fooandbarneed 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.
#=> trueBe careful of this when using has_many.
- Again,
foohas_manybars. - We want to build
barsin 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_byare unpersisted during the transaction. find_or_create_bylooks for a newbar, finds nothing, and returns a new object.- It is important to understand that while in the transaction,
find_or_create_byhas 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_entriestable 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 atransactionblock,ActiveRecorddoes 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_ofconstraint onbuzz_type, which Sinatra2 is violating. - calling
save!on the Foo in Sinatra2 will raise aActiveRecord::RecordInvaliderror 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
saveraises and when it returns false. savewill returnfalseif an ActiveRecord validation fails. This indicates that the record was not persisted.savewill raise anActiveRecord::WrappedDatabaseExceptionif 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.