The write that isn't in your transaction

Free preview 4 min to read

This application enqueues background work in a handful of places: its mailers, notification emails, partner webhooks, and the partner icon fetch. The simplest of them is app/services/admin/partners/save_partner_service.rb, shown here in full:

module Admin
  module Partners
    class SavePartnerService
      Result = Data.define(:success, :partner, :errors)

      def self.call(partner:, params:)
        partner.assign_attributes(params)

        if partner.save
          ::Partners::FetchSiteIconJob.perform_later(partner.id) unless partner.logo.attached?
          Result.new(success: true, partner: partner, errors: [])
        else
          Result.new(success: false, partner: partner, errors: partner.errors.full_messages)
        end
      end
    end
  end
end

Two writes happen there, one on each of two lines. partner.save writes a row. perform_later writes a job. The interesting question is where each one lands.

Four databases, not one

This app's config/database.yml declares four connections in development, and each is a separate database:

# config/database.yml, development (database names shortened)
development:
  primary:
    database: app_development
  cache:
    database: app_development_cache
  queue:
    database: app_development_queue
  cable:
    database: app_development_cable

Solid Queue is pointed at the queue database explicitly, in config/environments/development.rb, test.rb and production.rb:

config.solid_queue.connects_to = { database: { writing: :queue } }

So partner.save writes to the primary database, and perform_later writes a row to the queue database. Different databases, different connections, two separate commits.

Why that's worth a whole course

Solid Queue stores jobs in a database table and not in Redis, which sounds like it should make the enqueue part of your transaction. It doesn't, because the table lives in a different database. A BEGIN on the primary connection has no authority over the queue connection. Wrapping the two lines in ActiveRecord::Base.transaction wouldn't change that.

This is the shape the rest of the course is about. Before it gets a pattern name, here it is plainly: two writes, to two systems, that can't be made atomic.

The failure this code has

Read the ordering again. partner.save commits first. Only then does perform_later run.

Suppose the process dies between those two lines, say the container is killed mid-request or the machine loses power. The partner row is committed, and the job was never enqueued. Nothing retries it and nothing logs it. The partner stays without the site icon that FetchSiteIconJob would have fetched, until an admin happens to save it again.

Nobody sees an error, because from the request's point of view the save succeeded, and it did.

There's a louder variant. If the queue database refuses the connection, perform_later raises after the partner is already committed. The admin gets an error page for a save that went through.

Here the stakes are a missing logo, which is a fine thing to lose. That's what makes it a good first example and not an alarming one. The mechanism is the same whether the lost job sends a receipt, charges a card or fetches a favicon.

The other ordering isn't better

The obvious fix is to enqueue first and save second, so the job is never lost. That trades one failure for a worse one. For a new partner there's no id yet to pass. For an existing one, the job can run before the save commits, or after a save that failed. FetchSiteIconJob#perform starts with ::Partners::Partner.find_by(id: partner_id), and when that returns nil the guard on the next line does nothing.

Neither order is safe, and no third order exists. That's the whole problem.

One more detail worth knowing

Development doesn't use Solid Queue at all. This app's own development.rb sets:

config.active_job.queue_adapter = :async

:async runs jobs on an in-process thread pool and keeps its queue in memory, so a job enqueued in development is lost on restart, with no database involved. Production and the test suite use :solid_queue. Reproducing a lost job locally therefore takes a deliberate setup; a well-timed crash alone won't do it.

What the seven lessons cover

Lesson 2 works through why no ordering of two writes is safe, and why "wrap it in a transaction" isn't available across two systems. Lesson 3 introduces the outbox itself: write the message as an ordinary row, in the same transaction as the data, so there's only one write to make atomic. Lesson 4 covers the relay that drains that table and what its own crashes mean. Lesson 5 is about the guarantee you get, at-least-once, and the duplicates that come with it. Lesson 6 works out what adopting this here would cost, against the real schema. Lesson 7 argues the other side, because most Rails applications shouldn't build one.

The short version, which the last lesson defends: this app's missing-icon window is real, and an outbox is almost certainly the wrong tool for it.

Lesson 1 of 7 · free

Keep going with lesson 2

Why two writes cannot be atomic

Explains why no ordering of two writes is safe and why after_commit and retries leave the window open.

A free account also gives you the quizzes, your progress and notes, and the discussion.

Type to start searching...