What happens to the jobs already in the queue when I rename their class?
The code change looks straightforward: rename the file, update the callers, and fix the tests.
But a job enqueued before the deploy might wait hours or days before a worker picks it up. By then, the old class is gone.
I wanted to follow that job through the rename and work out what the new release still needs to support. Keeping the old class around seems reasonable, but then we need to decide when we can remove it.
Let’s start by removing it.
Suppose we rename FulfillOrderJob to DispatchOrderJob, update every call site, and deploy. New requests now enqueue DispatchOrderJob. A job scheduled by the previous release still contains these fields in its stored Active Job payload:
{
"job_class": "FulfillOrderJob",
"job_id": "14310a91-bc6f-4392-9ae1-792a12c92ed8",
"arguments": [42],
"enqueued_at": "2026-09-24T10:00:00.000000000Z"
}When a worker running the new release reads that payload, it fails:
ActiveJob::UnknownJobClassError:
Failed to instantiate job, class `FulfillOrderJob` doesn't existThe reproduction confirms this on Rails 8.1.3. The deploy changed the files the workers load, while the worker still looks for FulfillOrderJob because that name remains in the payload. To understand what we need to preserve, it helps to see exactly where Rails stops.
The worker supplies the implementation
Before the rename, calling FulfillOrderJob.perform_later(42) hands a job instance to the enqueue path. Solid Queue stores the payload Active Job produces during serialization, including the job class name and serialized arguments. The method implementation stays in the application code.12
When a worker receives that payload, it creates another job instance through this sequence:
The producer and worker can run different releases at each end of this sequence. They may even run those releases at the same time during a rolling deployment.
In Rails 8.1.3, ActiveJob::Base.deserialize resolves job_class with safe_constantize and raises ActiveJob::UnknownJobClassError if the class is absent. Once it has a job instance, Rails restores the metadata, including the still-serialized arguments. Argument deserialization happens later inside perform_now.3
Renaming the file and updating current callers changes what new requests enqueue. Supporting the already-stored class name should be handled separately.
The failure happens before the job can handle it
The next thing I wanted to check was whether a handler in ApplicationJob could catch this error. It is a job failure, after all, and that is where we usually put job exception handling.
But Rails has not constructed a job instance yet. The regular job exception handlers run inside instance perform_now, which execution hasn't reached yet. Adding retry_on ActiveJob::UnknownJobClassError to ApplicationJob therefore does not catch this failure.
The backend can still record it. In the reproduction using Rails 8.1.3 and Solid Queue 1.4.0, one process enqueued FulfillOrderJob, and a second booted without that class. Solid Queue retained a failed execution containing ActiveJob::UnknownJobClassError; the handler inherited from ApplicationJob was never entered. Solid Queue catches the exception around ActiveJob::Base.execute and records the failed result.4
To see why the failure stage matters, keep the class name and change its method instead:
# Previous release
def perform(order_id)
# ...
end
# New release
def perform(order_id, warehouse)
# ...
endThe older payload still contains [42]. Class lookup and argument deserialization succeed. Ruby then rejects the call because the new method requires two arguments. This ArgumentError occurs inside the job’s exception-handling path. A matching handler can intercept it, although retrying unchanged code with unchanged arguments will produce the same failure.
Custom serializers introduce another dependency. Their payloads carry a serializer name in _aj_serialized; the worker must resolve that name and read the older data format. Removing the serializer can cause an ActiveJob::DeserializationError, with the underlying exception available as its cause. Keeping the name while changing which fields its reader requires can break the same payload later in deserialization.5
That wrapper also covers missing records: a GlobalID argument pointing to a deleted Active Record row raises ActiveRecord::RecordNotFound, which Active Job wraps in ActiveJob::DeserializationError. Check exception.cause to distinguish a missing record from a removed custom serializer. A Record Passed to Active Job Becomes an Identifier, Not a Snapshot follows the record lookup in detail.6
This gives us a useful starting point for debugging: compare the stored class name and argument structure with the exception and the worker’s release. Enqueue time helps narrow the search, but Active Job’s standard payload has no application release SHA. Use deployment records or application-added metadata to establish which code produced and consumed the work. Avoid dumping sensitive argument values into logs merely to identify their structure.
Active Job defines reconstruction and dispatch. The application owns compatibility between releases. Solid Queue records the failure in this example; another backend needs its own inspection path.
Keeping the old class gets us part of the way
For a rename with unchanged behavior and arguments, a small compatibility class may be enough:
# app/jobs/dispatch_order_job.rb
class DispatchOrderJob < ApplicationJob
def perform(order_id)
# Existing fulfillment implementation, unchanged by this rename.
end
end
# app/jobs/fulfill_order_job.rb
class FulfillOrderJob < DispatchOrderJob
endThe old constant remains loadable. An old payload constructs FulfillOrderJob, which inherits the compatible implementation. This passes the reproduction: the older payload executes successfully through the inherited method.
At this point, it is tempting to update the callers and ship both classes together. That works for a new worker reading an old job. During a rolling deploy, though, an older worker may still be running when a new application process enqueues DispatchOrderJob. That worker has never heard of the new class.
We need to account for both directions, which changes the rollout:
Deploy code that understands both class names while producers continue writing the old name.
Once every worker that can receive the work understands both names, switch producers to the new name.
Retain the old reader while old payloads and old producers can still return.
The rollback target also needs both names once producers begin using the new one. Rolling back to the original release would restore code that cannot read DispatchOrderJob payloads already stored in the queue. A compatibility release gives you a more useful rollback target.

Argument changes follow the same rule. Adding a default lets a new worker accept an older payload:
def perform(order_id, warehouse = "default")
# ...
endBut an old worker whose method accepts only order_id still rejects [42, "west"]. Deploy the optional reader everywhere before producers send the second argument. The default must also preserve the intended behavior for old work; choosing whichever warehouse is convenient today could quietly change how yesterday’s order gets fulfilled.
For a plain rename, I’d skip payload versioning. An explicit payload version becomes useful when the data’s meaning or format changes enough to need separate reading rules. It still needs a reader, and it cannot help Rails find a removed job class. Similarly, an inheritance shim is appropriate only as long as the inherited behavior remains compatible. If the new implementation changes what fulfillment means, the old entry point needs deliberate translation or must preserve its existing behavior.
Next: What retry_on Actually Means. When a job fails halfway through, which work runs again, and what has already happened?
Subscribe free for the next article in The Background Execution Boundary.
Test a payload the new code did not create
To check these cases, the producer and consumer need different definitions of the job. A test that enqueues and performs using only the new release can pass throughout the rename failure because both ends already agree on the new contract.
Save a complete serialized payload from the previous release as test/fixtures/files/fulfill_order_job_v1.json and commit it. Inside a Rails test running the new code, load and execute that fixture:
payload = JSON.parse(file_fixture("fulfill_order_job_v1.json").read)
ActiveJob::Base.execute(payload)Set up any records or dependencies the job needs, then assert the intended business result. Merely returning without an exception is insufficient: a handler could swallow the failure, or the job could accept the payload and do the wrong thing.
Argument compatibility can be tested in the same process against the current implementation. For the class-rename check, use a fresh process so an already-loaded old constant cannot hide the failure.
The runnable reproduction writes JSON in one Ruby process and reads it in another. It uses order ID 42 and a harmless return value instead of performing real fulfillment. The direct Active Job checks produced these results:
Class names: The original class and compatible subclass both execute successfully. Removing the old class raises
UnknownJobClassErrorbefore the job handler runs.Older arguments: Adding a required argument raises
ArgumentErrorinside the handler path. Adding an optional argument lets the old payload execute.Newer arguments: The original worker rejects the new two-argument payload with
ArgumentError, also inside the handler path.
These checks call ActiveJob::Base.execute directly to isolate compatibility. The separate Solid Queue check uses its actual enqueue, claim, execution, and failure-recording paths with a temporary SQLite database. It verifies failure retention; deployment orchestration remains outside the reproduction.
Keep the old fixture fixed while changing the reader; regenerating every fixture from the latest serializer removes the historical contract the test was meant to preserve.
Removing the old class needs evidence
That leaves the other half of the original question: how long does FulfillOrderJob need to stay?
Once new callers use DispatchOrderJob, the old class looks unused in the repository. I’d want evidence from the queue and the running processes before deleting it.
Scheduled jobs may be waiting for next month. Failed executions may remain available for an operator to retry. An older scheduler, console process, or application instance may still produce the old name. A rollback may reintroduce those producers after today’s inspection is complete.
Check recurring-task definitions too. Solid Queue’s default config/recurring.yml can still name FulfillOrderJob under class: even after every Ruby caller has been updated. Updating that file and retiring an old scheduler are separate parts of the rollout.7
Inspect those sources before choosing a removal date. In a Rails console connected to the queue database, start with unfinished Solid Queue jobs under the old class name:
old_jobs = SolidQueue::Job.where(class_name: "FulfillOrderJob", finished_at: nil)
old_jobs.order(:id).limit(20).pluck(:id, :active_job_id, :scheduled_at)This returns up to 20 rows with the queue row ID, Active Job ID, and scheduled time, excluding retained completed jobs. Examine their scheduled, ready, claimed, blocked, or failed execution state separately; scheduled time alone doesn't determine their current state. Use the Active Job ID to connect a failure to its payload and worker logs. An empty result still needs the producer and rollback checks above. Restoring compatibility makes a failed payload readable again; recovering the failed work is a separate operational action.
On a very large jobs table, check the deployed indexes and the query’s EXPLAIN plan; LIMIT 20 bounds the result, not the rows examined. Solid Queue 1.4.0 supplies separate indexes on class_name and finished_at. The absence of a composite index doesn’t by itself imply a sequential scan: PostgreSQL can use or combine separate indexes. Let the actual plan guide any index change.8
If you already use Mission Control: Jobs, its job-class filter provides a visual alternative. Filter for FulfillOrderJob and inspect the relevant job statuses before retiring the compatibility class.
Draining can shorten the transition when you can stop producers and complete the remaining work deliberately. It becomes less attractive when a job is scheduled far ahead or retained for later recovery. Keeping a small compatibility class may cost less than coordinating every source of old work around one deploy.
Give that class a removal condition tied to the system: old producers retired, outstanding old payloads resolved, and rollback targets accounted for. Record those conditions beside the compatibility code, then verify them before deleting the old entry point.
Have you had to keep an old class around longer than expected? I’d be interested to hear what kept it there.
Rails 8.1.3 ActiveJob::Core defines the serialized fields, class lookup, and job metadata restoration.
Rails 8.1.3 ActiveJob::Execution reconstructs the job before entering instance perform_now, where it deserializes arguments, dispatches, and handles exceptions as usual.
Solid Queue 1.4.0 ClaimedExecution invokes Active Job and records unsuccessful execution. FailedExecution persists the exception class, message, and backtrace.
Rails 8.1.3 ActiveJob::Serializers resolves the stored serializer name; ActiveJob::Arguments wraps argument-deserialization failures.
The Active Job guide’s missing-record discussion describes deserialization failures when a record passed to a job has been deleted.
Solid Queue 1.4.0’s recurring-task documentation describes the default config/recurring.yml file and its class: entries.
Solid Queue 1.4.0’s generated queue schema defines the default indexes. PostgreSQL documents how to combine multiple indexes and the planner’s trade-offs.


It's weird how an article you skim triggers such odd emotions.
2-3 years ago this type of article was probably was a huge life saver to someone.
Today I'm like why even bother writing this besides for AI SEO