1. What is Active Job?
The Active Job Rails framework allows you to declare background jobs and execute them on a queuing backend. It provides a consistent, high-level interface for common asynchronous tasks such as sending emails, processing data, or performing periodic maintenance tasks.
The goal of background jobs is to move long-running or non-critical work out of the HTTP request-response cycle and into a background queue (such as the default Solid Queue), and keep the web requests fast and responsive. This separation allows applications to perform work asynchronously, scale background processing independently, and execute multiple tasks in parallel without blocking user interactions.
2. Creating Jobs
This section provides a step-by-step guide for defining a job Ruby class and
then using the perform_* method to enqueue work to be executed in the
background.
2.1. Defining a Job
Active Job provides a Rails generator to create jobs. The following will create
a job in the app/jobs directory (with tests under test/jobs):
$ bin/rails generate job guests_cleanup
invoke test_unit
create test/jobs/guests_cleanup_job_test.rb
create app/jobs/guests_cleanup_job.rb
If you don't want to use a generator, you can create your own file inside of
app/jobs and define a class that inherits from ApplicationJob.
Here's what a job looks like:
class GuestsCleanupJob < ApplicationJob
queue_as :default
def perform(*guests)
# Do something later
end
end
Note that you can define the perform method inside a job class with as many
arguments as you want.
If your application uses a custom abstract job base class instead of
ApplicationJob, you can use the --parent option with the generator. The
parent class must itself inherit from ApplicationJob. This can be useful for
grouping related functionality in one place.
For example, given a custom abstract job class using queue_as:
class PaymentJob < ApplicationJob
queue_as :payments
end
You can generate a new job that inherits from it:
$ bin/rails generate job process_payment --parent=payment_job
The above creates a class that will use the payments queue:
class ProcessPaymentJob < PaymentJob
def perform(*args)
# Do something later, uses the "payments" queue
end
end
2.2. Calling the perform_* Methods
Once you have defined a job class with a perform method, you'd typically call
it using perform_later to enqueue the work to be executed on a queuing
backend. Or use perform_now if you want the job to execute immediately
without queueing. Both perform_later and perform_now call perform under
the hood.
In the examples below, the methods can be called from anywhere in your Rails application, most commonly from controllers, models, or other jobs.
# To run a job immediately without enqueuing it
GuestsCleanupJob.perform_now(guest)
# To enqueue a job to be performed later
GuestsCleanupJob.perform_later(guest)
Use the
set
method to specify exactly when to perform a job.
# Enqueue a job to be performed tomorrow at noon
GuestsCleanupJob.set(wait_until: Date.tomorrow.noon).perform_later(guest)
# Enqueue a job to be performed one week from now
GuestsCleanupJob.set(wait: 1.week).perform_later(guest)
Since both perform_now and perform_later forward their arguments to
perform, you can pass as many arguments as defined in perform, including
keyword arguments:
GuestsCleanupJob.perform_later(guest1, guest2, filter: "some_filter")
2.2.1. Example: Sending Email
One of the most common jobs in a modern web application is sending emails to users. Active Job can do this outside of the request-response cycle, so the user doesn't have to wait on it. Active Job is integrated with Action Mailer so you can easily send emails asynchronously:
# If you want to send the email now, use #deliver_now
UserMailer.welcome(@user).deliver_now
# If you want to send the email asynchronously, use #deliver_later
UserMailer.welcome(@user).deliver_later
The deliver_now and deliver_later methods are Action Mailer's counterparts
to perform_now and perform_later. Under the hood, deliver_later works by
enqueuing an ActionMailer::MailDeliveryJob — a built-in Active Job job that
Rails provides — which goes through the same queuing pipeline as any job you
define yourself, and will eventually call the perform method in your Job
class.
2.3. Supported Argument Types for perform
ActiveJob supports the following types of arguments by default:
- Basic types (
NilClass,String,Integer,Float,BigDecimal,TrueClass,FalseClass) SymbolDateTimeDateTimeActiveSupport::TimeWithZoneActiveSupport::DurationHash(Keys should be ofStringorSymboltype)ActiveSupport::HashWithIndifferentAccessArrayRangeModuleClass
Active Job supports GlobalID for arguments. This makes it possible to pass live Active Record objects to your job instead of class/id pairs, which you then have to manually deserialize.
For example, instead of having to do something like this:
class GuestsCleanupJob < ApplicationJob
def perform(guests_class, guests_id, depth)
guests = guests_class.constantize.find(guests_id)
guest.cleanup(depth)
end
end
Using GlobalID, you can simply do:
class GuestsCleanupJob < ApplicationJob
def perform(guest, depth)
guest.cleanup(depth)
end
end
This works with any class that mixes in GlobalID::Identification, which is
mixed into Active Record by default.
2.3.1. Add Custom Types by Defining Serializers
You can extend the list of supported argument types by defining your own
serializer for your custom types. A serializer needs three methods: serialize,
deserialize, and klass.
The serialize method converts an object into a simpler representation using
only supported types. The recommended approach is to return a Hash with string
keys, calling super to let Active Job merge in the serializer's type
information:
# app/serializers/money_serializer.rb
class MoneySerializer < ActiveJob::Serializers::ObjectSerializer
def serialize(money)
super(
"amount" => money.amount,
"currency" => money.currency
)
end
def deserialize(hash)
Money.new(hash["amount"], hash["currency"])
end
def klass
Money
end
end
The deserialize method receives that hash and reconstructs the original
object. The klass method returns the class this serializer handles so Active
Job can use it to determine which serializer to apply to a given argument.
Once a serializer is defined, it needs to be added to the list of serializers Rails knows about:
# config/initializers/custom_serializers.rb
Rails.application.config.active_job.custom_serializers << MoneySerializer
Custom Active Job serializers are registered during application initialization and are expected to remain stable for the lifetime of the process. Reloadable autoloading is not supported in this context.
To ensure serializers are loaded only once (and not reloaded in development), place them in an autoload_once_paths directory, such as:
# config/application.rb
module YourApp
class Application < Rails::Application
config.autoload_once_paths << "#{root}/app/serializers"
end
end
3. Enqueuing Jobs
3.1. Naming Queues
With Active Job you can schedule the job to run on a specific queue using
queue_as:
class GuestsCleanupJob < ApplicationJob
queue_as :low_priority
# ...
end
When using a generator, you can also create a job that will run on a specific queue:
$ bin/rails generate job guests_cleanup --queue low_priority
You can prefix the queue name for all your jobs using
config.active_job.queue_name_prefix in application.rb:
# config/application.rb
module YourApp
class Application < Rails::Application
config.active_job.queue_name_prefix = Rails.env
end
end
# app/jobs/guests_cleanup_job.rb
class GuestsCleanupJob < ApplicationJob
queue_as :low_priority
# ...
end
Now your job will run on queue production_low_priority on your production
environment and on staging_low_priority on your staging environment.
You can also configure the prefix on a per job basis.
# This will override the global prefix and this job won't be prefixed.
class GuestsCleanupJob < ApplicationJob
queue_as :low_priority
self.queue_name_prefix = nil
# ...
end
The default queue name prefix delimiter is '_'. This can be changed by setting
config.active_job.queue_name_delimiter in application.rb:
# config/application.rb
module YourApp
class Application < Rails::Application
config.active_job.queue_name_prefix = Rails.env
config.active_job.queue_name_delimiter = "."
end
end
# app/jobs/guests_cleanup_job.rb
class GuestsCleanupJob < ApplicationJob
queue_as :low_priority
# ...
end
Now the queue will be named production.low_priority or staging.low_priority.
You can control the queue at the job level by passing a block to queue_as. The
block will be executed in the job context (so it can access self.arguments),
and it's return value must be a queue name. For example:
class ProcessVideoJob < ApplicationJob
queue_as do
video = self.arguments.first
if video.owner.premium?
:premium_videojobs
else
:videojobs
end
end
def perform(video)
# Do process video
end
end
last_video = Video.last
ProcessVideoJob.perform_later(last_video)
If you want more control on what queue a job will be run you can pass a :queue
option to set:
MyJob.set(queue: :another_queue).perform_later(record)
One way to name queues is based on latency. So instead of "critical", "default", or "low", queues could be named "within_30_seconds", "within_5_minutes", and "within_1_hour". This can be enforced like a contract by configuring your queuing backend to notify your engineering team if a job sits in a given queue longer than the corresponding time.
If you choose to use an alternate queuing backend you may need to specify the queues to listen to.
3.2. Queue Priority
You can schedule a job to run with a specific priority using
queue_with_priority:
class GuestsCleanupJob < ApplicationJob
queue_with_priority 10
# ...
end
Solid Queue, the default queuing backend, prioritizes jobs based on the order of the queues. If you're using Solid Queue with both queue order and priority option, the queue order will take precedence, and the priority option will only apply within each queue.
Other queuing backends may allow jobs to be prioritized relative to others within the same queue or across multiple queues. You can check the documentation of your backend for the specifics.
Similar to queue_as, you can also pass a block to queue_with_priority to be
evaluated in the job context:
class ProcessVideoJob < ApplicationJob
queue_with_priority do
video = self.arguments.first
if video.owner.premium?
0
else
10
end
end
def perform(video)
# Process video
end
end
last_video = Video.last
ProcessVideoJob.perform_later(last_video)
You can also pass a :priority option to set:
MyJob.set(priority: 50).perform_later(record)
If a lower priority number performs before or after a higher priority number depends on the adapter implementation. Refer to the documentation of your backend for more information. Adapter authors are encouraged to treat a lower number as more important, as a convention.
3.3. Bulk Enqueuing
You can enqueue multiple jobs at once using
perform_all_later.
Bulk enqueuing reduces the number of round trips to the queue data store (such
as Redis or a database), making it a more performant operation than enqueuing
the same jobs individually.
The perform_all_later method accepts instantiated jobs as arguments (note that
this is different from perform_later) and calls perform under the hood. The
arguments passed to new, when creating new job instances, will be passed on to
perform when it's eventually called. For example:
# Create jobs to pass to `perform_all_later`.
# The arguments to `new` are passed on to `perform`
cleanup_jobs = Guest.all.map { |guest| GuestsCleanupJob.new(guest) }
# Will enqueue a separate job for each instance of `GuestsCleanupJob`
ActiveJob.perform_all_later(cleanup_jobs)
# Can also use `set` method to configure options before bulk enqueuing jobs.
cleanup_jobs = Guest.all.map { |guest| GuestsCleanupJob.new(guest).set(wait: 1.day) }
ActiveJob.perform_all_later(cleanup_jobs)
The perform_all_later call logs the number of jobs successfully enqueued, for
example if Guest.all.map above resulted in 3 cleanup_jobs, it would log
Enqueued 3 jobs to Async (3 GuestsCleanupJob) (assuming all were enqueued).
The return value of perform_all_later is nil. Note that this is different
from perform_later, which returns the instance of the queued job class.
3.3.1. Enqueue Multiple Active Job Classes
With perform_all_later, it's also possible to enqueue different Active Job
class instances in the same call. For example:
class ExportDataJob < ApplicationJob
def perform(*args)
# Export data
end
end
class NotifyGuestsJob < ApplicationJob
def perform(*guests)
# Email guests
end
end
# Instantiate job instances
cleanup_job = GuestsCleanupJob.new(guest)
export_job = ExportDataJob.new(data)
notify_job = NotifyGuestsJob.new(guest)
# Enqueues job instances from multiple classes at once
ActiveJob.perform_all_later(cleanup_job, export_job, notify_job)
3.3.2. Bulk Enqueue Callbacks
When enqueuing jobs in bulk using perform_all_later, callbacks such as
around_enqueue will not be triggered on the individual jobs. This behavior is
in line with other Active Record bulk methods. Since callbacks run on individual
jobs, they can't take advantage of the bulk nature of this method.
However, the perform_all_later method does fire an
enqueue_all.active_job
event which you can subscribe to using ActiveSupport::Notifications.
The method
successfully_enqueued?
can be used to find out if a given job was successfully enqueued.
3.3.3. Queue Backend Support
For perform_all_later, bulk enqueuing needs to be backed by the queue backend.
Solid Queue, the default queue backend, supports bulk enqueuing using
enqueue_all.
Other backends like Sidekiq have a push_bulk
method, which the Sidekiq adapter users. internally to push a large number of
jobs to Redis and prevent the round trip network latency. GoodJob also supports
bulk enqueuing with the GoodJob::Bulk.enqueue method.
If the queue backend does not support bulk enqueuing, perform_all_later will
enqueue jobs one by one.
4. Callbacks
Active Job provides hooks to trigger logic during the life cycle of a job. Like other callbacks in Rails, you can implement them as ordinary methods and register them using a class-level method:
class GuestsCleanupJob < ApplicationJob
queue_as :default
around_perform :around_cleanup
def perform
# Do something later
end
private
def around_cleanup
# Do something before perform
yield
# Do something after perform
end
end
These class-level methods also accept a block, which works well when the callback logic is short enough to fit on a single line. For example, sending metrics for every enqueued job:
class ApplicationJob < ActiveJob::Base
before_enqueue { |job| Rails.logger.info "Enqueuing #{job.class.name}" }
end
4.1. Available Callbacks
There are several callbacks that Active Job supports.
before_enqueueruns before a job is enqueued.around_enqueuewraps the enqueuing process, allowing logic to run both before and after.after_enqueueruns after a job is enqueued.
For example:
class GuestsCleanupJob < ApplicationJob
before_enqueue { |job| Rails.logger.info "About to enqueue #{job.class.name}" }
around_enqueue { |job, block| block.call }
after_enqueue { |job| Rails.logger.info "Successfully enqueued #{job.class.name}" }
end
before_performruns before a job is performed.around_performwraps the perform process, allowing logic to run both before and after.after_performruns after a job is performed.
For example:
class GuestsCleanupJob < ApplicationJob
before_perform { |job| Rails.logger.info "About to perform #{job.class.name}" }
around_perform { |job, block| block.call }
after_perform { |job| Rails.logger.info "#{job.class.name} performed successfully" }
end
Lastly, after_discard runs when a job is discarded due to an unhandled
exception:
class GuestsCleanupJob < ApplicationJob
after_discard { |job, exception| Rails.logger.error "#{job.class.name} discarded: #{exception.message}" }
end
Please note that when enqueuing jobs in bulk using perform_all_later,
callbacks such as around_enqueue will not be triggered on the individual jobs.
See Bulk Enqueuing Callbacks.
4.2. Halting Callbacks
You can halt the callback chain by throwing :abort. This works the same way as
in Active Record and other Rails callbacks. For example, to prevent a job from
being enqueued based on a condition:
class GuestsCleanupJob < ApplicationJob
before_enqueue do |job|
throw :abort if ENV.fetch("DISABLE_GUESTS_CLEANUP_JOB", true)
end
def perform(guest)
# ...
end
end
When :abort is thrown in a before_enqueue callback, the job will not be
enqueued and perform_later will return false. When thrown in a
before_perform callback, the job will not be performed. It will also skip the
execution of any subsequent before, around and after callbacks.
Throwing an :abort does not trigger after_discard. The after_discard
callback is specifically tied to the discard_on mechanism.
5. Job Continuations
Active Job Continuations allow jobs to be split into resumable steps, so that long-running jobs can make progress after interruptions. When using continuations, the job automatically resumes from the last completed step, instead of restarting from the beginning.
To use continuations, include the ActiveJob::Continuable module in your Job
class. You can then define each step inside the perform method using
step.
class ProcessImportJob < ApplicationJob
include ActiveJob::Continuable
def perform(import_id)
# Always runs on job start, even when resuming from an interrupted step.
@import = Import.find(import_id)
# Step defined using a block
step :initialize do
@import.initialize
end
step :process do
@import.records.find_each { |record| record.process }
end
# Step defined by referencing a method
step :finalize
end
private
def finalize
@import.finalize
end
end
Each step can be declared with a block or by referencing a method name. The block will be called with the step object as an argument. Methods can either take no arguments or a single argument for the step object.
Steps are executed as soon as they are encountered. Code that is not part of a step will be executed on each job run. If a job is interrupted, previously completed steps will be skipped. If a step is in progress, it will be restarted or resumed with the last recorded cursor if using cursors.
5.1. Using a Cursor
Steps can also use an optional cursor to track progress within the step. The code in the step is responsible for using the cursor to continue from the appropriate location after an interruption. For example:
class ProcessImportJob < ApplicationJob
include ActiveJob::Continuable
def perform(import_id)
# Always runs on job start, even when resuming from an interrupted step.
@import = Import.find(import_id)
# Step with a cursor
step :process do |step|
@import.records.find_each(start: step.cursor) do |record|
record.process
step.advance!
end
end
end
end
In the above example, the cursor tracks the id of the last successfully
processed record. If the job is interrupted midway through a large import, it
resumes from where it left off rather than reprocessing records from the
beginning, passing the saved cursor value to find_each.
5.2. Job Attributes
The continuable steps may need to share state. Active Job attributes let jobs
declare typed state using the Active Model Attributes API, so that values
computed in one step are available in later steps. Attribute values are
serialized when the job is interrupted or retried, and restored when the job
resumes. ActiveJob::Continuable includes ActiveJob::Attributes, so
continuable jobs can declare attributes directly.
In the example below, the payment_token and billing_profile_id attributes
are declared at the class level so their values are preserved across
interruptions. They are computed in tokenize_payment_instrument step and used
in the submit_enrollment step later:
class SubmitEnrollmentJob < ApplicationJob
include ActiveJob::Continuable
attribute :payment_token, :string
attribute :billing_profile_id, :integer
def perform(enrollment)
step :tokenize_payment_instrument do
self.payment_token = PaymentGateway.tokenize(enrollment.user.payment_instrument)
end
step :create_billing_profile do
self.billing_profile_id = BillingProfileApi.create(customer_id: enrollment.user_id)
end
# payment_token and billing_profile_id are restored from the serialized
# job state when resuming here after an interruption.
step :submit_enrollment do
submission_id = EnrollmentApi.submit(enrollment, payment_token, billing_profile_id)
enrollment.update!(status: "processing", submission_id: submission_id)
end
end
end
Attribute values must be serializable as Active Job supported argument types. For more details, see ActiveJob::Attributes.
Job Continuations make it easier to build long-running or multi-phase jobs that can safely pause and resume without losing progress. For more details, see ActiveJob::Continuation.
6. Default Backend: Solid Queue
Solid Queue is a database-backed queue backend for Active Job and the default queue backend for Rails version 8.0 onwards. Rather than requiring a separate infrastructure dependency like Redis, Solid Queue uses your existing database to persist and process jobs. It supports delayed jobs, job priorities, concurrency controls, recurring jobs, and bulk enqueuing.
6.1. Setup and Default Configuration
Solid Queue is already configured for production by default. For example, if you
open config/environments/production.rb, you will see the following:
# config/environments/production.rb
# Replace the default in-process and non-durable queuing backend for Active Job.
config.active_job.queue_adapter = :solid_queue
config.solid_queue.connects_to = { database: { writing: :queue } }
Additionally, the database connection for the queue database is configured in
config/database.yml:
# config/database.yml
# Store production database in the storage/ directory, which by default
# is mounted as a persistent Docker volume in config/deploy.yml.
production:
primary:
<<: *default
database: storage/production.sqlite3
queue:
<<: *default
database: storage/production_queue.sqlite3
migrations_paths: db/queue_migrate
The key queue from the database configuration needs to match the key
used in the configuration for config.solid_queue.connects_to (as highlighted
in the code snippets above).
In order to start using Solid Queue, run db:prepare so your database has Solid
Queue related tables:
$ bin/rails db:prepare
You can find the schema for the queue database in db/queue_schema.rb,
which is generated automatically. It will contain tables like
solid_queue_jobs, solid_queue_recurring_executions,
solid_queue_scheduled_executions, and more.
Finally, to start the queue and start processing jobs you can run:
$ bin/jobs start
6.1.1. Development Environment
Rails provides an asynchronous in-process queuing backend, which keeps the jobs
in memory. With the default async adapter, if the process crashes or the
machine is reset, then all outstanding jobs are lost. This can be acceptable for
non-critical jobs in development.
Alternatively, you can use Solid Queue in development. It can be configured in the same way as in the production environment:
# config/environments/development.rb
config.active_job.queue_adapter = :solid_queue
config.solid_queue.connects_to = { database: { writing: :queue } }
Add queue to the development database configuration:
# config/database.yml
development:
primary:
<<: *default
database: storage/development.sqlite3
queue:
<<: *default
database: storage/development_queue.sqlite3
migrations_paths: db/queue_migrate
6.2. Workers, Dispatchers, Supervisors
Solid Queue uses three types of processes to handle job queueing and execution:
- Workers poll queues for jobs that are ready to run and execute them.
- Dispatchers handle scheduled jobs — they check for jobs due to run in the future and move them into the ready queue for workers to pick up.
- A Supervisor manages both workers and dispatchers, by forking and monitoring them.
When you run bin/jobs start, you're starting the supervisor process, which in
turn forks and manages the workers and dispatchers according to the
configuration in config/queue.yml. Here is an example of the default
configuration:
# config/queue.yml
default: &default
dispatchers:
- polling_interval: 1
batch_size: 500
workers:
- queues: "*"
threads: 3
processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %>
polling_interval: 0.1
The configuration in config/queue.yml is optional. If no configuration is
provided, Solid Queue will run with one dispatcher and one worker with default
settings. Below are some of the configuration options you can set along with
their default values`:
| Option | Description | Default Value |
|---|---|---|
| polling_interval | Time in seconds workers/dispatchers wait before checking for more jobs. | 1 second (dispatchers), 0.1 seconds (workers) |
| batch_size | Number of jobs dispatched in a batch. | 500 |
| concurrency_maintenance_interval | Time in seconds the dispatcher waits before checking for blocked jobs that can be unblocked. | 600 seconds |
| queues | List of queues workers fetch jobs from. Supports * for all queues or queue name prefixes. |
* |
| threads | Maximum size of the thread pool for each worker. Determines how many jobs a worker fetches at once. | 3 |
| processes | Number of worker processes forked by the supervisor. Each process can dedicate a CPU core. | 1 |
| concurrency_maintenance | Whether the dispatcher performs concurrency maintenance work. | true |
You can read more about these configuration options in the Solid Queue
documentation.
There are also additional configuration
options
that can be set in config/<environment>.rb to further configure Solid Queue in
your Rails Application.
6.3. Queue Order and Priority
Solid Queue offers two distinct mechanisms for controlling the order in which jobs are processed: queue ordering and numeric priorities. Understanding how they interact is important for getting the behavior you expect.
6.3.1. Queue Order
Queue order is the primary way to prioritize work in Solid Queue. The order in
which queues are listed in config/queue.yml for a worker determines the
polling order. A worker will not pull jobs from a lower priority queue (listed
later in the array) until all higher priority queues are empty:
production:
workers:
- queues: [critical, default, low]
threads: 5
With the above configuration, no jobs will be taken from the default queue
while the critical queue has jobs waiting, and no jobs will be taken from
low while either critical or default has jobs waiting. Solid Queue has
strict ordering (unlike other queuing backend which may allow relative weights
so that lower priority queues still receive a proportional share of processing
time). It is possible for lower queues to be starved if higher queues are
consistently busy.
It is possible to use a wildcard * within queue names. For example if the
worker is configured with queues:[active_storage*, mailers], it will fetch
jobs from queues starting with "active_storage", such as the
active_storage_analyze queue and active_storage_transform queue. Only when
no jobs remain in the active_storage-prefixed queues will workers move on to
the mailers queue.
Using wildcard queue names (e.g., queues: active_storage*) can slow
down polling performance in SQLite and PostgreSQL due to the need for a
DISTINCT query to identify all matching queues, which can be slow on large
tables. For better performance, it’s best to specify exact queue names instead
of using wildcards.
6.3.2. Numeric Priorities
Numeric priorities apply within a single queue. You can assign numeric
priorities to jobs using queue_with_priority. Lower numbers indicate higher
priority, with a default of 0:
class CriticalReportJob < ApplicationJob
queue_as :default
queue_with_priority 0
end
class RoutineCleanupJob < ApplicationJob
queue_as :default
queue_with_priority 10
end
When both jobs are in the default queue, CriticalReportJob will be picked up
first. However, numeric priority only applies within a queue. It has no effect
across queues. Queue order takes precedence, if you are using both mechanisms
together.
6.3.3. Polling Interval
For Solid Queue the polling_interval setting for a worker directly affects how
quickly it picks up new jobs. A high-priority queue paired with a slow polling
interval may not feel very responsive in practice:
production:
workers:
- queues: critical
threads: 5
polling_interval: 0.1 # Poll every 100ms — fast response
- queues: low
threads: 2
polling_interval: 10 # Poll every 10s — fine for low-priority work
Tuning polling_interval per worker is especially important for time-sensitive
queues.
6.3.4. Retries
In Solid Queue, retries need to be configured explicitly using Active Job's
retry_on:
class ExternalApiJob < ApplicationJob
retry_on Net::TimeoutError, wait: :exponentially_longer, attempts: 5
retry_on ActiveRecord::Deadlocked, wait: 2.seconds, attempts: 3
def perform
# ...
end
end
This can also be set globally in ApplicationJob if you want a default retry
policy across all jobs. Failed jobs that aren't configured with retry_on will
go straight to failed executions without retrying.
6.4. Concurrency Controls
Solid Queue extends Active Job with concurrency controls, allowing you to limit how many jobs of a certain type can run at the same time. This is useful for protecting shared resources, such as ensuring only one export job runs per account at a time, or capping the number of concurrent API calls to an external service.
Concurrency controls are declared using limits_concurrency in your job class:
class InvoiceExportJob < ApplicationJob
limits_concurrency to: 1, key: ->(account_id) { "invoice_export_#{account_id}" }, duration: 10.minutes
def perform(account_id)
# ...
end
end
In the above example:
- The
:tooption sets the maximum number of jobs that can run concurrently. - The
:keylambda computes a concurrency key from the job's arguments. In the example above, the limit of 1 applies per account rather than globally. - The
:durationoption acts as a failsafe. So if a worker dies mid-job and fails to release its lock, any blocked jobs become candidates for release once duration has elapsed.
When a job with concurrency controls is enqueued, Solid Queue checks a
database-backed lock for the computed key. If the lock is available, the job is
marked ready for execution. If not, the behavior depends on the :on_conflict
option. If on_conflict is set to :block (the default), the job is held in a
blocked state and marked ready only when a running job finishes. The other
option is :discard, in which case the job is dropped entirely.
You can also scope limits across different job classes using the :group
option:
class AnalyticsExportJob < ApplicationJob
limits_concurrency to: 1, key: ->(account_id) { account_id }, group: "account_exports", duration: 10.minutes
end
class InvoiceExportJob < ApplicationJob
limits_concurrency to: 1, key: ->(account_id) { account_id }, group: "account_exports", duration: 10.minutes
end
In the above example, both job classes share the same concurrency limit per the "account_exports" group, which means only one export of either type will run at a time for a given account.
Concurrency controls do carry overhead since blocked executions must be tracked and locks created and updated. So they should be used sparingly. For simple throughput limiting, constraining the number of worker threads per queue is more efficient.
Concurrency controls are not compatible with bulk enqueuing via
perform_all_later. Since concurrency-controlled jobs need to be enqueued
one-by-one to respect the configured limits.
6.5. Error handling
Solid Queue raises SolidQueue::Job::EnqueueError when an Active Record error
occurs during job enqueuing. This differs from ActiveJob::EnqueueError, which
Active Job handles internally by making perform_later return false. The
practical consequence is that errors become harder to handle for jobs enqueued
by Rails internals or third-party gems like Turbo::Streams::BroadcastJob,
since you don't control the call to perform_later in those cases. For
recurring tasks, enqueue errors are logged but not raised. See Errors When
Enqueuing
in the Solid Queue documentation for more detail.
If a worker process is killed unexpectedly — for example, with a KILL signal —
any in-flight jobs are marked as failed, and errors such as
SolidQueue::Processes::ProcessExitError or
SolidQueue::Processes::ProcessPrunedError are raised. Heartbeat settings
control how quickly Solid Queue detects and cleans up expired processes. See
Threads, Processes and
Signals
in the Solid Queue documentation for details on configuring this behavior.
If your error tracking service doesn't automatically capture job errors, you can
hook into Active Job's rescue_from in ApplicationJob:
class ApplicationJob < ActiveJob::Base
rescue_from(Exception) do |exception|
Rails.error.report(exception)
raise exception
end
end
If your application uses Action Mailer, note that mailer delivery runs through
ActionMailer::MailDeliveryJob, which inherits from ApplicationJob but needs
to be handled separately:
class ApplicationMailer < ActionMailer::Base
ActionMailer::MailDeliveryJob.rescue_from(Exception) do |exception|
Rails.error.report(exception)
raise exception
end
end
6.6. Transactional Integrity on Jobs
Since Solid Queue can use the same database as your application, it can participate in the same ACID transactions as your application data. But this behavior comes with important nuances worth understanding before you rely on it.
When Solid Queue uses the same database as your application, job enqueuing happens inside the same transaction as any surrounding Active Record operations. This means a job won't be enqueued if the transaction rolls back, and the transaction won't commit unless the job enqueue also succeeds. This eliminates a class of race conditions common with Redis backends (e.g. a job running before the record it needs has been committed to the database).
However, Rails 8 configures Solid Queue on a separate database by default, precisely to avoid implicit coupling to this behavior. If you build logic that depends on transactional integrity and later move Solid Queue to its own database or switch to a different backend, that behavior silently disappears. The separate database default is the safer choice for most applications.
6.6.1. Using enqueue_after_transaction_commit
The recommended way to get transactional safety — without depending on both your
app and Solid Queue sharing the same database — is to use
enqueue_after_transaction_commit. This defers job enqueuing until the
surrounding Active Record transaction successfully commits, and can be enabled
per job or globally:
class ApplicationJob < ActiveJob::Base
self.enqueue_after_transaction_commit = true
end
With this setting, a job enqueued inside a transaction that rolls back will simply not be enqueued. This gives you the guarantee portably, regardless of whether Solid Queue shares a database with your app or not.
6.6.2. Enqueuing from after_commit Callbacks
If you prefer not to use enqueue_after_transaction_commit, the alternative is
to always enqueue jobs from after_commit callbacks rather than from within
transactions directly:
after_commit :schedule_cleanup, on: :create
def schedule_cleanup
GuestsCleanupJob.perform_later(self)
end
This ensures the job is only enqueued once the relevant data is durably committed to the database.
6.6.3. The Risk of Implicit Reliance
The subtle danger is enqueuing a job inside a transaction without either of the
above safeguards in place. In that case, the job may run before the data it
needs is visible to other connections, or it may be enqueued even if the
transaction rolls back. This is easy to overlook if you're accustomed to
Redis-backed backends where this problem doesn't arise in the same form. If
you're unsure whether your code relies on transactional integrity, enabling
enqueue_after_transaction_commit globally in ApplicationJob is the safest
default.
You can read more about Transactional Integrity in the Solid Queue documentation
6.7. Recurring Tasks
Solid Queue supports recurring tasks, similar to cron jobs. These tasks are
defined in a configuration file (by default, config/recurring.yml) and can be
scheduled at specific times. Here's an example of a task configuration:
production:
a_periodic_job:
class: MyJob
args: [42, { status: "custom_status" }]
schedule: every second
a_cleanup_task:
command: "DeletedStuff.clear_all"
schedule: every day at 9am
Each task specifies a class or command and a schedule (parsed using
Fugit). You can also pass arguments to
jobs, such as in the example for MyJob where args are passed. This can be
passed as a single argument, a hash, or an array of arguments that can also
include keyword arguments as the last element in the array.
You can learn more about Recurring Tasks in the Solid Queue documentation.
7. Alternate Queuing Backends
While Solid Queue is the default queuing backend in Rails, Active Job is designed to work seamlessly with different queuing backends. Switching to an alternative backend, such as Sidekiq, GoodJob, or Resque, requires only a configuration change (typically with no modifications to your job code), along with adding the queuing backend's adapter to your Gemfile.
Here is a noncomprehensive list of alternate queuing backends and documentation:
To switch backends globally, you can set config.active_job.queue_adapter in
your application configuration:
# config/application.rb
module YourApp
class Application < Rails::Application
config.active_job.queue_adapter = :sidekiq
end
end
You can also set the adapter per-environment, which is useful if you want to use Solid Queue in production but a simpler adapter in development:
# config/environments/development.rb
config.active_job.queue_adapter = :async
If you want to migrate incrementally, you can set the adapter at the job class level. This is useful for moving one job at a time rather than switching everything at once:
class MyJob < ApplicationJob
self.queue_adapter = :sidekiq
end
Each backend requires its own gem and typically its own process. Once you add
the adapter's gem to your Gemfile, you can refer to the adapter's
documentation for any additional setup — most backends require a separate worker
process to be started alongside your Rails application, and some (like Sidekiq)
require additional infrastructure such as Redis.
Note that switching backends doesn't migrate jobs already sitting in the old queue. You'll need to drain the old queue before switching, or run both backends in parallel temporarily to let existing jobs complete.
The early releases of Active Job had adapters built-in, but a decision was later made to let queueing backends providers manage the adapter themselves. Any backend can be used with Active Job regardless of whether the adapter is built in or not.
If you use config.active_job.queue_name_prefix, make sure your new
backend's worker configuration listens to the prefixed queue names, not the bare
names.
8. Monitoring and Handling Failed Jobs
8.1. Monitoring With Mission Control
The Mission Control engine is a Rails-based frontend to Active Job adapters to help centralize the monitoring and management of failed jobs. It provides insights into job status, failure reasons, and retry behaviors, enabling you to track and resolve issues more effectively.
For instance, if a job fails to process a large file due to a timeout,
mission_control-jobs allows you to inspect the failure, review the job’s
arguments and execution history, and decide whether to retry, requeue, or
discard it.
8.2. Detecting Errors With rescue_from
Exceptions raised during the execution of the job can be handled with
rescue_from:
class GuestsCleanupJob < ApplicationJob
queue_as :default
rescue_from(ActiveRecord::RecordNotFound) do |exception|
# Do something with the exception
end
def perform
# Do something later
end
end
If an exception from a job is not rescued, then the job is referred to as "failed".
You can enable additional logging to figure out where jobs are coming from with verbose logging.
8.3. Retrying or Discarding Failed Jobs
A failed job will not be retried, unless configured otherwise.
It's possible to either retry or discard a failed job by using retry_on or
discard_on, respectively. For example:
class RemoteServiceJob < ApplicationJob
retry_on CustomAppException # defaults to 3s wait, 5 attempts
discard_on Net::OpenTimeout
def perform(*args)
# Might raise CustomAppException or Net::OpenTimeout
end
end
8.4. Missing Records
GlobalID will use the unique identifier to locate the full Active Record object
when calling #perform.
If a passed record is deleted after the job is enqueued but before the
#perform method is called Active Job will raise an
ActiveJob::DeserializationError
exception.