ruby 117 lines · 4 tabs

Transaction-Safe After-Commit Hook (Avoid Ghost Jobs)

Shared by codesnips Jan 2026
4 tabs
class Order < ApplicationRecord
  include TransactionalEnqueue

  belongs_to :customer
  has_many :line_items, dependent: :destroy

  enum status: { pending: 0, fulfilling: 1, fulfilled: 2, failed: 3 }

  validates :reference, presence: true, uniqueness: true

  after_commit :enqueue_fulfillment, on: :create

  private

  def enqueue_fulfillment
    # Runs only after the creating transaction has committed for real.
    perform_after_commit do
      FulfillmentJob.perform_later(id)
    end
  end
end
4 files · ruby Explain with highlit

A "ghost job" is a background job that runs against a database row that was never actually committed, or that reads stale data because the enqueue fired inside a transaction that later rolled back. When jobs are enqueued from an after_save or after_create callback, the record only exists in the uncommitted transaction; a fast worker can dequeue and look up the row before COMMIT lands, or the enqueue survives even though the transaction rolls back. This snippet shows the idiomatic Rails fix built around after_commit plus a helper that defers work until the surrounding transaction has truly committed.

In Order model, the enqueue is moved out of after_save and into after_commit on: :create. The enqueue_fulfillment method calls perform_after_commit, a small concern method that guarantees the block runs only when no open transaction remains. Passing id (not the object) to the job matters: after_commit runs after the row is durable, but the worker still re-fetches the row so it never trusts an in-memory copy.

The TransactionalEnqueue concern centralizes the logic. It inspects self.class.connection.transaction_open? (and whether that transaction is a real one via current_transaction.joinable?) to decide whether to run immediately or register a completion callback with add_transaction_record style tracking. When still inside a transaction it uses connection.add_transaction_record and an after_commit callback on the connection's current transaction, so the block fires exactly once on commit and is silently dropped on rollback.

FulfillmentJob is deliberately defensive: it re-loads the Order by id and returns early if the record is missing or already processed, giving idempotency against retries and any residual races. The find_by guard turns a would-be crash into a no-op, which is the correct behavior for a job whose triggering transaction may have been rolled back before delivery.

OrdersController wraps creation and inventory reservation in a single ActiveRecord::Base.transaction. Because the job is only enqueued on commit, a failed reservation rolls everything back with zero ghost jobs. The main trade-off is a slightly later enqueue and reliance on Rails' connection-level transaction bookkeeping, which is stable but framework-internal. This pattern is the standard answer whenever a job must never observe uncommitted or rolled-back state.


Related snips

Share this code

Here's the card — post it anywhere.

Transaction-Safe After-Commit Hook (Avoid Ghost Jobs) — share card
Link copied