Cat sitting in a box ← Blog

Running ActiveJob after transaction

Transactions!

If you know how to cook them they can be a real life saver when it comes to dealing with inconsistent data.

But…

There’s always a “but”, am I right?

Unfortunately at some point you add code to your system that has side effects outside of your database, and obviously transactions won’t help you with that.

One of such things is Active Job (unless it’s based on Delayed Job, which stores your jobs in the same database).

Imagine the following scenario:

class Checkout
  class << self
    def call
      ActiveRecord::Base.transaction do
        cart = get_cart
        order = OrderCreator.call(cart)
        ChargeCustomer.call(order)
      end
    end
  end
end

class OrderCreator
  class << self
    def call(cart)
      order = Order.create!(cart)
      SendSmsJob.perform_later(order.customer.phone, order.new_order_message)
      PrepareForShippingJob.perform_later(order.id)
      order
    end
  end
end

class SendSmsJob < ApplicationJob
  def perform(phone, message)
    send_sms(phone, message)
    twilio.messages.create(
      to: phone,
      body: message
    )
  end
end

class PrepareForShippingJob < ApplicationJob
  def perform(order_id)
    order = Order.find(order_id)
    prepare_for_shipping(order)
  end
end

What is wrong here? A lot actually!

First of all the SendSmsJob. The SMS is sent regardless of whether a charge via ChargeCustomer succeeds or not. If the charge fails the customer is still going to get the SMS, even though the whole transaction was rolled back. Confusing experience.

Second PrepareForShippingJob (it could be any other job referencing the order). What happens if the job is executed before the transaction commits? Payment gateways are not the fastest kids on the block, and queues like Sidekiq are pretty fast. So if the order does exist in one database session, it doesn’t exist in the one that your Active Job is executing, so you get an annoying RecordNotFound error. Yuck!

Rails magic to the rescue

Turns out ActiveRecord keeps a list of records which were a part of a transaction and calls committed! on them. If we inspect active_record/transactions.rb we can pretend to be one of those records.

class Committable
  def initialize(klass, *args)
    @klass = klass
    @args = args
  end

  def committed!(*_)
    @klass.perform_later(*@args)
  rescue => e
    # failed to enqueue the job
    Rails.logger.warn(e.message)
  end

  def has_transactional_callbacks?
    true
  end

  def before_committed!
    # do nothing
  end

  def rolledback!(*_)
    # do nothing
  end
end

Now all that is left is to add ourselves to the list of records in a transaction.

class AfterTransaction
  class << self
    def call(klass, *args)
      if ActiveRecord::Base.connection.transaction_open?
        ActiveRecord::Base.
          connection.
          current_transaction.
          add_record(Committable.new(klass, *args))
      else
        klass.perform_later(*args)
      end
    end
  end
end

Let’s see how our code is going to change.

class Checkout
  class << self
    def call
      ActiveRecord::Base.transaction do
        cart = get_cart
        order = OrderCreator.call(cart)
        ChargeCustomer.call(order)
      end
    end
  end
end

class OrderCreator
  class << self
    def call(cart)
      order = Order.create!(cart)
      AfterTransaction.call(SendSmsJob, order.customer.phone, order.new_order_message)
      AfterTransaction.call(PrepareForShippingJob, order.id)
      order
    end
  end
end

Not bad, not bad at all. With this change if the code is running within a transaction it’s going to wait until the transaction is over and then perform actions with uncontrollable side effects.

Having said that, I have to note that this is not a silver bullet. Also, the problem described above could be mitigated by restructuring our code. But nevertheless this is a very handy tool to have. Cheers!