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 from the beginning of this message has the same 
execution-timing problem, but is clearer IMHO because it’s not promising via 
naming things that it can’t actually deliver.

—Matt Jones


-- 
You received this message because you are subscribed to the Google Groups "Ruby 
on Rails: Core" group.
To unsubscribe from this group and stop receiving emails from it, send an email 
to rubyonrails-core+unsubscr...@googlegroups.com.
To post to this group, send email to rubyonrails-core@googlegroups.com.
Visit this group at http://groups.google.com/group/rubyonrails-core.
For more options, visit https://groups.google.com/d/optout.

Attachment: signature.asc
Description: Message signed with OpenPGP using GPGMail

Reply via email to