The request fails with SolidQueue::Job::EnqueueError.
That sounds like a clean failure. The controller returned a 500, so the team expects the order transaction to have rolled back. Instead, the order is present in the primary database, the queue database contains no FulfillOrderJob, and retrying the request collides with the order’s uniqueness constraint.
The code responsible for both writes is short:
class FulfillOrderJob < ApplicationJob
self.enqueue_after_transaction_commit = true
def perform(order_id)
# ...
end
end
Order.transaction do
order = Order.create!(checkout_token: params[:checkout_token])
FulfillOrderJob.perform_later(order.id)
endThe exception did not roll back the order because it arrived too late. The primary database had already committed. Rails was then attempting another durable write through Solid Queue, and that write failed.
Consider the following scenario:
Primary transaction opens
→ order row is written
→ perform_later registers a deferred enqueue
→ primary transaction commits
→ Solid Queue tries to write the queue row
→ queue insert raises
In the end, the application has a committed order and no queued fulfillment work. The request reports both operations as one failure, even though the databases returned separate outcomes.
Deferring a job until after commit controls the order of two writes. It doesn’t place those writes in one transaction.
perform_later can return before the adapter runs
The return value from perform_later makes this failure easy to misread.
When enqueue_after_transaction_commit runs inside an open Active Record transaction, Rails doesn't call the queue adapter immediately. Its deferred enqueue path does roughly this:
self.successfully_enqueued = true
ActiveRecord.after_all_transactions_commit do
self.successfully_enqueued = false
super # continue to the ordinary adapter enqueue
end
selfThe job object comes back while the transaction is still open. At that point, Rails has registered work to perform after commit. Solid Queue has not accepted anything yet.1
A runnable reproduction on Rails 8.1.3 and Solid Queue 1.4.0 exposes the intermediate state. Run it with separate to use independent application and queue databases, or same to put both sets of tables on one connection.
Order.transaction do
order = Order.create!(checkout_token: "checkout-123")
job = FulfillOrderJob.perform_later(order.id)
job.successfully_enqueued? # => true
job.provider_job_id # => nil
SolidQueue::Job.count # => 0
endNo enqueue.active_job notification has fired either. That event wraps the real adapter attempt, which is postponed until the transaction commits.2
successfully_enqueued? therefore has a narrow meaning during this interval. Rails accepted the deferred enqueue path. The value is not evidence that the backend accepted a job. A provider_job_id assigned by Solid Queue and the corresponding solid_queue_jobs row are much stronger evidence of that handoff.
Once the transaction commits, the registered block resets the flag and calls the ordinary enqueue path. A successful run now looks like this:
inside transaction
→ order row visible to this transaction
→ successfully_enqueued? = true
→ provider_job_id = nil
→ queue row absent
after primary commit
→ Solid Queue insert commits
→ provider_job_id assigned
→ enqueue.active_job fires successfully
The same temporary job object can first say true, then become unsuccessful if the later adapter call raises. It represents future enqueue intent before it represents backend acceptance.
The commit is already over when enqueueing fails
Active Record’s transaction manager commits the database transaction before it runs the commit callbacks. In the Rails 8.1.3 implementation, the important ordering is:
transaction.commit
transaction.commit_records
-> after_commit callbacks
-> deferred Active Job enqueueBy the time Solid Queue attempts its insert, the primary database has acknowledged the commit. There is no open primary transaction left to roll back.3
Now suppose the queue database rejects the insert because its jobs table is unavailable, a constraint fails, or the database aborts the statement. Solid Queue converts an Active Record persistence error into SolidQueue::Job::EnqueueError and lets it escape.4
That class is separate from ActiveJob::EnqueueError, which Active Job rescues and stores on job.enqueue_error.5 After this Solid Queue failure, successfully_enqueued? is false, provider_job_id is still nil, and enqueue_error is also nil. The exception itself or the exception_object attached to the failed enqueue.active_job event carries the cause. Checking only enqueue_error would miss it.
The exception can still travel up the request stack:
primary database: COMMIT
queue database: ROLLBACK
request: SolidQueue::Job::EnqueueErrorAn exception reaching the controller doesn't mean every earlier write was undone. Here it says the post-commit callback failed. The callback boundary explains the apparently contradictory result: an error response, a durable order, and a missing job can all be correct observations.
A process exit in the same interval produces a quieter failure. The primary commit can finish, and the process can stop before the deferred block reaches Solid Queue. There may be no enqueue exception or Active Job event at all. The durable state is still an order without its fulfillment job.
Commit deferral closes one race and leaves another gap
Calling the adapter before commit would remove the post-commit interval, but it produces a different failure when the queue uses separate storage.
Consider the request rolling back after an immediate enqueue:
primary transaction opens
→ order row is written
→ Solid Queue commits a job in the queue database
→ application raises
→ primary transaction rolls back
The job survives while the order disappears. A worker can also claim it before the primary transaction commits and fail to find the order.
Commit deferral gives this sequence instead:
primary transaction opens
→ order row is written
→ enqueue is deferred
→ primary transaction rolls back
→ deferred enqueue is discarded
That is an important guarantee. Work dependent on committed application state cannot escape a rolled-back transaction through this enqueue path. If the primary commit succeeds, however, the later queue write must still succeed on its own.
These two arrangements choose which write may survive alone:
Immediate enqueue, separate queue database
→ queue row commits first
→ if the application transaction rolls back, the job survives without the application row
Deferred enqueue, separate queue database
→ application row commits first
→ if the queue insert fails, the application row survives without the job
No ordering makes two independent database commits atomic. Moving the adapter call changes the exposed interval; it cannot remove it.
Solid Queue can participate in the application transaction
Solid Queue’s database-backed design offers another topology. If its models use the application’s current transactional connection, an immediate queue insert can join the same database transaction as the order:
one database transaction
→ order row is written
→ Solid Queue row is written
→ both commit or both roll back
This is why Solid Queue documents transactional integrity as a deployment and configuration choice. A separate queue database is the common arrangement, while using the same database can make enqueueing part of the application’s transaction.6
The exact connection matters. Two connection pools can point at the same PostgreSQL database and still hold independent transactions. For example, config.solid_queue.connects_to = { database: { writing: :queue } } gives Solid Queue its own pool and transaction context, even when queue and primary resolve to the same database URL. Sharing a hostname, database name, or SQLite file doesn’t merge them. The application row and queue row need to be written through the same transactional connection.
Deferral also changes the guarantee even when both models use the same database. With enqueue_after_transaction_commit enabled, Solid Queue is invoked after the application commit. The opportunity to join that transaction has passed. To make the queue row part of the local transaction, the adapter call must happen while that transaction is open and through its connection.
This design couples correctness to the queue topology. Moving Solid Queue to its own database later changes the atomicity guarantee even if the job call sites remain untouched. That may be a reasonable trade, but it belongs in the system’s contract, not in an accidental database configuration.
The shared transaction covers creating the application row and the queue row. It doesn’t make the job’s eventual network request, email, payment call, or any other side effect part of that transaction. Worker failures and repeated delivery remain separate problems.
Decide where the obligation lives
Not every missing job deserves an outbox.
A cache refresh, search reindex, or replaceable notification may be cheap to reconstruct. For that class of work, post-commit enqueueing, visible failures, and a reconciliation task can be enough. The application accepts the gap because another process can discover stale state and repair it.
If creating the queue row is part of the local write contract, keeping Solid Queue on the same connection is the smaller design. One transaction supplies one outcome. The dependency on that topology should be tested and documented.
If the queue must stay separate and losing the work is unacceptable, the primary transaction needs to preserve the obligation itself. A transactional outbox does that by writing an application-owned delivery record beside the business row:
primary transaction
→ order row
→ outbox row: “enqueue fulfillment for order 42”
→ commit both
publisher
→ reads pending outbox row
→ attempts Solid Queue enqueue
→ retries until the handoff is recorded
The outbox does not combine the primary and queue databases into one transaction. It gives the application a durable fact it can use to recreate the missing queue row. A publisher can crash, retry, and potentially enqueue twice, so the design still needs a way to recognize completed handoffs and tolerate repeated delivery. Those costs are worthwhile only when the work’s obligation warrants them.
The practical design question is:
Where is the durable record of the obligation to perform this work?
With a shared transaction, the queue row can be that record. With an outbox, the outbox row is. With repairable work, the current application state may be enough for a reconciler to infer it. A callback registered in process memory is not durable evidence.
Next in The Background Execution Boundary: a queued job can outlive the code version that created its payload.
Subscribe free for the next week’s source-backed look at job compatibility across a Rails deploy.
Reconstruct the handoff before changing retries
When an application row exists without its expected job, first establish the order of durable events.
Find the primary transaction outcome in the database or application telemetry. Then check whether the job class or global Active Job configuration deferred enqueueing. Identify the database configuration and connection pool used by SolidQueue::Record; “same database server” is too imprecise.
Use the Active Job ID to correlate the request logs, enqueue.active_job, and solid_queue_jobs.active_job_id. Interpret the evidence by phase:
a job object with
successfully_enqueued? == trueandprovider_job_id == nilinside an open transaction may only represent deferred intent;a successful
enqueue.active_jobevent shows that the adapter call returned successfully;a Solid Queue provider ID and backend row show that Solid Queue accepted the job;
a primary commit followed by
SolidQueue::Job::EnqueueErrorplaces the failure after application state became durable;a primary commit with no enqueue event may indicate that the process stopped before the deferred adapter call.
Retention can remove old Solid Queue rows, so absence long after the event is weaker evidence than absence at the time of failure. Recent failed enqueue attempts, however, should be investigated at the handoff boundary before worker retry settings enter the discussion. A worker cannot retry a job the queue never stored.
Finally, locate the durable record that can recreate the work. If no queue row, outbox row, audit event, or reconstructable application state exists, reconciliation has nothing authoritative to follow. That is the part to fix before the next committed transaction reaches the same gap.
If someone on your team expects after_commit to make the database write and queue write atomic, send them this article.
ActiveJob::EnqueueAfterTransactionCommit marks a deferred job as successfully enqueued, registers work with ActiveRecord.after_all_transactions_commit, and performs the ordinary enqueue later.
ActiveJob::Instrumentation wraps the real adapter call in enqueue.active_job. Solid Queue’s create_from_active_job assigns its row ID to provider_job_id after the insert succeeds.
Rails 8.1.3’s TransactionManager commits the database transaction before running commit records and callbacks. ActiveRecord.after_all_transactions_commit registers the deferred block against the open transactions.
SolidQueue::Job.create_from_active_job converts Active Record persistence errors into SolidQueue::Job::EnqueueError. The Solid Queue Active Job adapter sends the job through that path.
ActiveJob::Enqueuing rescues ActiveJob::EnqueueError, stores it in job.enqueue_error, and returns false. Solid Queue’s adapter-specific exception does not enter that rescue branch.
The Solid Queue 1.4.0 documentation describes the guarantee available when jobs share the application database and why a separate database avoids depending on it. The Rails Active Job guide documents transaction-aware enqueueing and its configuration.
