On Dec 19, 2014, at 8:18 PM, Jesse Storimer je...@shopify.com wrote:
I'd like to propose adding another callback to the ActiveRecord lifecycle
that triggers BEFORE the database transaction begins. Think of it as a
corollary to after_commit and after_rollback.
## Examples
This is an example resembling something we have to deal with in the Shopify
codebase. This is how it could be written using this new callback.
class CarrierService ActiveRecord::Base
validate :username, :password, presence: true
validate :validate_credentials
before_transaction :initiate_credential_validation
def initiate_credential_validation
logger.debug 'communicating with external carrier...'
@response = external_service.validate(username, password)
end
def validate_credentials
if ! @response.valid?
# add validation error here
end
end
end
Imagine an interaction like this:
carrier = CarrierService.new(username: 'jesse', password: 'password')
carrier.save
producing a log like this:
communicating with external carrier...
(0.1ms) begin transaction
SQL (0.4ms) INSERT INTO carrier_services (username, password) VALUES
(?, ?) [[username, jesse], [password, password]]
(1.9ms) commit transaction
Note that the external communication happens before the transaction begins.
We are doing this currently in our codebase, but it doesn't fit nicely into
the traditional ActiveRecord callback cycle.
Another obvious place where this would be beneficial is for models that
represent assets / uploads that want to fetch the asset on creation but not
inside the transaction.
It seems like a `before_save` that, in case of failure, adds something to
`errors` and returns false would accomplish the same thing here. It would only
run if the validations all otherwise pass, and it would make `save` return
false if it failed.
Something like:
class CarrierService ActiveRecord::Base
validate :username, :password, presence: true
before_save :validate_credentials
def validate_credentials
@response = external_service.validate(username, password)
unless @response.valid?
errors.add(:base, ‘YOU DONE GOOFED’)
false
end
end
end
## Motivation
The motivation for this addition is the same as for the after_commit
callback. Some models need to communicate with external services in order to
do validations / callbacks. To do so inside the DB transaction keeps that
transaction open unnecessarily and wreaks havoc on the DB.
## Gotchas
The above example involving a single model being saved with the callback
defined is the simple case. It can get more complicated in a scenario like
the following:
class Shop ActiveRecord::Base
end
class ProductImage ActiveRecord::Base
before_begin :fetch_image
end
shop = Shop.find(1)
shop.images ProductImage.new(src: '...')
shop.images ProductImage.new(src: '...')
# before opening the transaction to save the shop, we must look for autosave
associations
# and see if they have before_begin callbacks that need to be triggered
shop.save
We have prototyped a mostly functional supporting this relationship, so it
can certainly be accomplished.
This functionality would not play very nicely with the explicit transaction
methods.
For instance, when using .transaction this kind of callback could never be
triggered reliably.
shop = Shop.find(1)
Shop.transaction do
# we're already in a transaction at this point, so the callback has no hope
of being triggered outside of this parent transaction
Product.create(..., shop: shop)
ProductImage.create(..., shop: shop)
end
We would have a little more context when using the #transaction method given
that it's called on an instance that may have this callback defined (or have
unsaved associations with this callback defined), but it could be used
exactly as in the previous example and circumvent the process.
So the callback would fit in nicely during the regular `save` lifecycle, but
the explicit transaction methods would be a gotcha when using this feature.
The problem is that explicit transactions are implicitly part of *every* save.
Traversing associations beforehand is a decent approximation, but code that
updates / creates a record in an `after_save` is still going to require time
travel to achieve the intended semantics. In the case of your Product /
ProductImage setup, this would produce a bug:
class Product
has_many :product_images
after_save :create_default_image
def create_default_image
if product_images.empty?
product_images ProductImage.create(src: ‘some/default/path’)
end
end
end
ProductImage’s before_transaction callback CANNOT run at the right time here,
since it’s fired from a place that MUST already be contained in an open
transaction. Sadly, `gem install tardis` does not help. :)
The before_save suggestion