Proposal for before_transaction callback

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.

## 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.

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. :slight_smile:

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