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
module TransactionalEnqueue
extend ActiveSupport::Concern
def perform_after_commit(&block)
connection = self.class.connection
transaction = connection.current_transaction
if transaction_pending?(connection, transaction)
register_after_commit(transaction, &block)
else
yield
end
end
private
def transaction_pending?(connection, transaction)
connection.transaction_open? && transaction.joinable?
end
def register_after_commit(transaction, &block)
callback = AfterCommitCallback.new(block)
transaction.add_record(callback)
end
class AfterCommitCallback
def initialize(block)
@block = block
@committed = false
end
def has_transactional_callbacks?
true
end
def committed!(should_run_callbacks: true)
return if @committed
@committed = true
@block.call if should_run_callbacks
end
def rolledback!(force_restore_state: false, should_run_callbacks: true)
# Deliberately no-op: the enqueue is dropped so no ghost job survives.
end
def before_committed!(*)
end
def add_to_transaction(*)
end
end
end
class FulfillmentJob < ApplicationJob
queue_as :fulfillment
retry_on Redis::ConnectionError, wait: :polynomially_longer, attempts: 5
discard_on ActiveJob::DeserializationError
def perform(order_id)
order = Order.find_by(id: order_id)
return if order.nil? # transaction rolled back or row purged
return if order.fulfilled? # idempotent against retries and races
order.with_lock do
return if order.fulfilled?
order.update!(status: :fulfilling)
FulfillmentService.new(order).ship!
order.update!(status: :fulfilled, fulfilled_at: Time.current)
end
rescue FulfillmentService::PermanentError => e
order&.update(status: :failed, failure_reason: e.message)
end
end
class OrdersController < ApplicationController
def create
order = nil
ActiveRecord::Base.transaction do
order = Order.create!(order_params.merge(status: :pending))
InventoryReservation.reserve!(order) # raises on insufficient stock
end
render json: { id: order.id, status: order.status }, status: :created
rescue InventoryReservation::OutOfStock => e
# Transaction rolled back: no Order row, and crucially no FulfillmentJob.
render json: { error: e.message }, status: :unprocessable_entity
end
private
def order_params
params.require(:order).permit(:customer_id, :reference, line_items_attributes: %i[sku quantity])
end
end
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
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
Share this code
Here's the card — post it anywhere.