# The symptom: a 500 but the order exists and no job is queued
A request fails with SolidQueue::Job::EnqueueError. The controller shows a 500 and the team expects the order transaction to have rolled back. Instead, the order row is present in the primary database, there is no FulfillOrderJob in the queue database, and retrying the request hits the order's uniqueness constraint.
The application code is short and straightforward: an Order.transaction creates the order and calls FulfillOrderJob.perform_later(order.id), with the job configured to enqueue_after_transaction_commit. Yet the exception did not roll back the order because the queue write happened after the primary commit and then failed.
# Why this happens
Deferring a job until after commit guarantees only order: the primary DB commit happens before Rails asks the queue adapter to persist the job. It does not make the two writes a single atomic operation across databases.
When enqueue_after_transaction_commit is active, perform_later returns a job object immediately while the transaction is still open. Rails registers an after-commit hook that will run the real adapter enqueue only once the transaction commits. During the transaction the job object can show successfully_enqueued? = true, but that reflects Rails' deferred path, not backend acceptance.
A runnable repro on Rails 8.1.3 and Solid Queue 1.4.0 shows the intermediate state. Inside an Order.transaction:
- Order.create! writes the row.
- FulfillOrderJob.perform_later(order.id) returns a job with successfully_enqueued? => true and provider_job_id => nil.
- SolidQueue::Job.count is still 0 until the transaction commits and the deferred enqueue runs.
# Concrete signals of a successful enqueue
- provider_job_id assigned by Solid Queue
- a row in the solid_queue_jobs table (or equivalent queue table)
- the enqueue.active_job event carrying no exception
Relying on successfully_enqueued? alone during a transaction is misleading because it indicates intent rather than backend acceptance.
# Practical mitigations and operational steps
- Make application writes idempotent so retrying a controller action that produced a 500 won't collide badly. Uniqueness constraints should be paired with idempotent retry behavior.
- Treat provider_job_id and the queue table row as the ground truth for whether the queue accepted work. Instrument enqueue.active_job events and capture exception_object when enqueue failures occur.
- If you need stronger cross-system guarantees, change the architecture: shared storage or an outbox pattern moves both writes into a single durable handoff or into a single transactional authority so you don't rely on a post-commit callback for the second durable write.
- Add monitoring and alerting on enqueue failures, and track cases where the request returned an error but the primary DB shows a committed write.
# Bottom line
after_commit ensures order but not atomicity between two different durable systems. A post-commit queue insert can fail after the primary commit, leaving a durable record without corresponding queued work. Detecting and handling this requires different signals, idempotent operations, and, when necessary, architectural changes such as an outbox or shared storage.