Active Job schedules work through an adapter, but a queue handoff has its own lifetime. A job queued before the approval transaction commits may run against a case state that is invisible to its connection, or against a transaction that later rolls back. Rails can defer enqueue until after commit, which fixes that ordering problem across queue backends. It does not make the queue request itself a durable part of the case transaction when the adapter uses separate storage. If the process dies after commit and before enqueue, promised work is lost unless the database also contains an outbox row committed with the approval. A worker should receive an event ID, claim the row, and use stable identity for retries.
Rails Active Job After-Commit and Outbox Recovery
Working case
Case 62 changes to approved and a notification job is scheduled inside the transaction. On a fast worker, the job reads the previous status; on rollback, a notice may still be queued. Deferring enqueue until commit fixes those races. Then the web process dies just after commit and before it can reach the queue. With no other record, the notice disappears. The repaired service writes outbox event 93 with the case change. After commit, an Active Job wake-up can accelerate delivery. A separate sweep finds pending event 93 even when that wake-up was lost. The worker claims it under a lease, sends with a stable provider key where possible, and records delivered or review-needed state.
Implementation boundary
class PermitOutboxWakeJob < ApplicationJob
self.enqueue_after_transaction_commit = true
queue_as :notifications
def perform(event_id)
event = ApprovalOutbox.find_by(id: event_id)
return unless event&.pending?
PermitNotificationDispatcher.claim_and_deliver(event.id)
end
end
ApprovalOutbox.pending.order(:id).limit(47).pluck(:id).each do |event_id|
PermitOutboxWakeJob.perform_later(event_id)
endConfigure after-transaction enqueue behavior for the job or schedule it from a post-commit boundary, and verify the setting under the deployed Rails version and adapter. Treat that enqueue as a hint, not the only record of intent. Write the outbox event inside the same database transaction as the approved case and operation result. Give workers a narrow event ID instead of serializing an Active Record object whose state and permission may change. A claim must be atomic across workers; use a lease or row transition with expiry, finite retries, and an inspectable permanent-failure state. A scheduled sweep should scan a bounded number of pending rows and release expired claims. Recheck recipient policy if it can change before delivery.
Cost and boundaries
An outbox event adds a write and retention cost, while a sweep and worker pool add reads and operational capacity. Deferring enqueue can add a brief delay but prevents the worker from seeing uncommitted state. A database-backed queue in the same database may have different atomicity from a queue on another database or broker; the architecture should not rely on an accidental shared-transaction setup. Provider timeouts are ambiguous because a send may have succeeded before acknowledgment was lost. A stable event key or provider reconciliation narrows duplicate delivery but does not create a universal exactly-once guarantee. Measure oldest pending event age, queue lag, lease expiry, attempt count, provider outcomes, and permanent failures.
Failure trace
Roll back approval and verify there is neither a committed outbox event nor a worker-visible job for it. Commit approval and kill the web process before enqueue; restart the sweep and confirm event 93 is found. Run two workers against the same event and require a single active lease. Crash after claim, let the lease expire, and verify another worker can recover. Make the provider accept the notification but drop its acknowledgment; retry under the same event key and inspect whether the provider deduplicates or whether reconciliation is needed. Stop all workers and watch the oldest pending age rise. Force a permanent invalid recipient and confirm the event reaches an operator-visible state instead of retrying forever.
Verification
- The outbox row commits with the approved case.
- The wake-up job runs only after commit and receives an ID.
- A sweep finds pending events even when enqueue was lost.
Practice drill
Create a Rails notification outbox for permit approvals. Configure the wake-up job to enqueue after commit and pass only the outbox row ID. Have a sweep inspect at most 47 pending rows per pass; each worker must atomically claim a row, use a bounded attempt policy, and persist delivered or review-needed status. Inject failures before commit, after commit but before enqueue, during provider send, and after provider acceptance but before worker acknowledgment. Record the event's stable external key and the result of every attempt without copying private case fields into queue payloads. Verify behavior under the production queue adapter and its actual database topology.
Decision note
Use after-commit enqueue for ordering and a transactional outbox for recoverable delivery intent.
Common Mistakes
- Assuming after-commit enqueue makes a separate queue write atomic with the case transaction.
- Sending a stale model snapshot as the job payload.
- Retrying provider timeouts with a new unrelated identity.
Related lessons
Rails Request, Record, and Job Boundaries; Rails Controller Parameters and Object Permission; Rails Active Record Scope, Eager Load, and Page Cost; Rails Locking, Transaction, and Command Replay; Background Workflow Reliability; Laravel After-Commit Jobs and Outbox Recovery; Flask Async Views and Durable Background Work.
Apply and check
Build Project: Rails permit review workflow and review Web Development: Rails request, record, and job quiz.
