
Our previous article in this series established that Solid Queue is an excellent choice if you need a system for processing background jobs. It minimizes external dependencies — no need for Redis! — by storing all jobs in your database. Despite that, it is incredibly performant.
But just being performant is not enough for a production-ready background job system. Rails developers have come to expect a lot over the years. We don't just want to enqueue jobs to run in the background. We want to schedule jobs, run them on a recurring schedule, and we might even want to limit how many jobs can run concurrently. We want more features!
Amazingly, Solid Queue provides all of those features out of the box. Let's dive deeper into Solid Queue and learn how that's possible!
Scheduling Jobs with Solid Queue for Ruby on Rails
First, it's time for a small recap. Solid Queue uses your database — and only your database — to store job data. Everything it does is backed by one database table or another. Scheduling jobs — that is, designating jobs to run at some specific point in the future — is no different. Any scheduled job is stored in solid_queue_scheduled_executions
.
# lib/generators/solid_queue/install/templates/db/queue_schema.rb create_table "solid_queue_scheduled_executions", force: :cascade do |t| t.bigint "job_id", null: false t.string "queue_name", null: false t.integer "priority", default: 0, null: false t.datetime "scheduled_at", null: false t.datetime "created_at", null: false t.index [ "job_id" ], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true t.index [ "scheduled_at", "priority", "job_id" ], name: "index_solid_queue_dispatch_all" end
This table is almost identical to the solid_queue_ready_executions
table. The only difference is the addition of the scheduled_at
column, which tells us when a scheduled job is supposed to be executed. Let's confirm that by looking at what happens when we schedule a job.
MyJob.set(wait_until: Date.tomorrow.noon).perform_later
INSERT INTO "solid_queue_scheduled_executions" ("job_id", "queue_name", "priority", "scheduled_at", "created_at") VALUES (1, 'default', 0, '2025-04-02 12:00:00.000000', '2025-04-01 12:00:00.000000') RETURNING "id"
There are no surprises there. Solid Queue adds a new row to the solid_queue_scheduled_executions
table, which contains the data that we'd expect. But how do we go from such a record existing to actually running a job at the right time?
We need a process that continuously polls the solid_queue_scheduled_executions
table. That process is called the Dispatcher, and it is responsible for executing scheduled jobs on time. It begins when Solid Queue starts — no additional configuration is required. However, if needed you can start only the dispatcher process by running Solid Queue with a specific configuration.
# Start Solid Queue with this configuration, for example, with bin/jobs -c config/only_dispatcher.yml production: dispatchers: - polling_interval: 1 batch_size: 500 concurrency_maintenance_interval: 300
In case you were wondering how the Dispatcher process is supervised, that is the responsibility of the aptly named Supervisor
. It keeps track of any running processes within Solid Queue, including worker processes and Dispatchers.
So, how does the Dispatcher actually work? It defines a poll
method called within a loop to continuously check for scheduled jobs. The polling code is spread over several classes and modules, but in a heavily simplified form, it looks like this:
# lib/solid_queue/dispatcher.rb class Dispatcher < Processes::Poller # Batch size and polling interval are based on your configuration def poll job_ids = ScheduledExecution.due.ordered.limit(batch_size).pluck(:job_id) jobs = Job.where(id: job_ids) Job.dispatch_all(jobs).map(&:id).then do |dispatched_job_ids| ScheduledExecution.where(job_id: dispatched_job_ids).delete_all end end end
The query to retrieve 'ready' scheduled executions is straightforward.
SELECT "solid_queue_scheduled_executions".* FROM "solid_queue_scheduled_executions" WHERE "solid_queue_scheduled_executions"."scheduled_at" <= '2025-04-02 12:00:01.000000' ORDER BY "solid_queue_scheduled_executions"."scheduled_at" ASC, "solid_queue_scheduled_executions"."priority" ASC, "solid_queue_scheduled_executions"."job_id" ASC LIMIT 500
So, any scheduled job with scheduled_at
in the past is ready to be dispatched. As we covered in part one of this series, when Solid Queue dispatches a job, it creates a ReadyExecution
record and destroys the corresponding ScheduledExecution
record. The ReadyExecution
record is then picked up by regular worker processes, and the corresponding job runs.
So far, so good. Scheduled jobs are really not that complicated! Let's look at something more complex: recurring tasks.
Recurring Tasks
Recurring tasks are an oft-requested feature for background job processors. Simply put, they are background jobs that should run on a recurring schedule. They are similar to Cron jobs in that you define a schedule (such as every five minutes, every day at noon, and so on) for when work should occur.
In Solid Queue, you configure your recurring jobs using the config/recurring.yml
file. For example, if we wanted to run a CleanupData
job every day at noon, this is how we would do it.
production: cleanup_data: class: CleanupData args: [] schedule: every day at noon
Solid Queue uses Fugit to parse schedule expressions, which is why human-readable schedules such as 'every day at noon' are permitted. When using scheduled tasks, you define the class of the job to be run and any job arguments. The excellent SolidQueue recurring tasks ReadMe provides more details. We're here to learn how it works, so let's look under the hood.
Recurring tasks are represented by the RecurringTask
model, which is backed by a corresponding solid_queue_recurring_tasks
table. The columns therein correspond to the fields available in the configuration file.
# lib/generators/solid_queue/install/templates/db/queue_schema.rb create_table "solid_queue_recurring_tasks", force: :cascade do |t| t.string "key", null: false t.string "schedule", null: false t.string "command", limit: 2048 t.string "class_name" t.text "arguments" t.string "queue_name" t.integer "priority", default: 0 t.boolean "static", default: true, null: false t.text "description" # ... end
When you start SolidQueue, recurring task records are created according to your recurring tasks configuration file. To create jobs at the right time, we once again need a new process — this time called the Scheduler. The Scheduler is a sibling to the Dispatcher, which we already know about. It works in almost the same way: A new process is spun up when Solid Queue starts, and this process runs an endless loop. The difference between the Scheduler and the Dispatcher is what happens within that loop. Where the Dispatcher queries the solid_queue_scheduled_executions
table, the Scheduler queries solid_queue_recurring_tasks
— and schedules jobs at the right time. So, how exactly does the Scheduler know what the right time is, and when to schedule the right jobs?
To answer that question, we have to examine the implementation closely. The scheduler class creates a new RecurringSchedule
object that defines a schedule
method. That method is repeatedly called for each scheduled task. Here's a simplified version:
# lib/solid_queue/scheduler/recurring_schedule.rb def schedule(task) scheduled_task = Concurrent::ScheduledTask.new(task.delay_from_now, args: [ self, task, task.next_time ]) do |thread_schedule, thread_task, thread_task_run_at| thread_schedule.schedule(thread_task) thread_task.enqueue(at: thread_task_run_at) end scheduled_task.tap(&:execute) end
Let's untangle this code. Solid Queue uses Concurrent::ScheduledTask
(from the concurrent-ruby library) to spawn a new thread. That thread is scheduled to run at the time specified by the recurring task's schedule. When that thread runs, it first recursively spawns another thread to schedule the next recurring task. Then, it enqueues the 'current' scheduled job.
Let's look at an example of a simple recurring task to get a handle on things.
production: my_periodic_task: class: CleanupData args: [] schedule: every hour
If we start Solid Queue at 8:30, the variables within the schedule method are assigned the following values. Not verbatim, mind you. We're greatly simplifying here.
Variable | Value |
---|---|
task | my_periodic_task |
task.delay_from_now | 30 minutes |
task.next_time | 9:00 |
So, our background thread is scheduled to run thirty minutes from now, which is 9:00. When that time rolls around, the background thread is executed. It runs thread_task.enqueue(at: 9:00)
— so an instance of CleanupData
is queued for execution. It also calls itself recursively via thread_schedule.schedule
. Because it is now 9:00, the variables for this invocation have changed.
Variable | Value |
---|---|
task | my_periodic_task |
task.delay_from_now | 59 minutes |
task.next_time | 10:00 |
So, the background thread is scheduled to run again at 10:00, and the cycle continues. You may be wondering what happens if the scheduling thread is killed, for example during a re-deploy or system crash. Won't that upset your schedules? Luckily, the answer is no. Cron schedules are static. An expression like 'Every Hour' always resolves to 10:00, 11:00, 12:00, and so on, regardless of when Solid Queue starts. Any interruptions to the scheduling thread don't change that.
Here are some other implementation details to be aware of. First, this pattern of scheduling the next occurrence of a recurring task before executing it is inspired by GoodJob. Second, RecurringTask.enqueue
does not create a new Job
and ReadyExecution
record as you might expect. Instead, it creates yet another record, namely RecurringExecution
.
# lib/generators/solid_queue/install/templates/db/queue_schema.rb create_table "solid_queue_recurring_executions", force: :cascade do |t| t.bigint "job_id", null: false t.string "task_key", null: false t.datetime "run_at", null: false # ... t.index [ "job_id" ], name: "index_solid_queue_recurring_executions_on_job_id", unique: true t.index [ "task_key", "run_at" ], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true end
This record is solely to avoid executing recurring jobs multiple times. It has an index on task_key
and run_at
with unique constraints to serve that purpose. A RecurringTask
is only queued if there is no prior RecurringExecution
for the same time and the same job.
# app/models/solid_queue/recurring_task.rb def enqueue(at:) active_job = if using_solid_queue_adapter? # Create a RecurringExecution and enqueues the job enqueue_and_record(run_at: at) else # ... end rescue RecurringExecution::AlreadyRecorded payload[:skipped] = true false end
Attentive readers will notice that this code snippet points to a limitation in Solid Queue. That is, if you are not using Solid Queue as a backend to run your cron-style tasks — yes, you can do that — Solid Queue can't guarantee that recurring jobs are enqueued only once. If you find yourself in such a situation, you should be aware of that.
You may also be wondering what happens if the Scheduler process dies or is killed — for example, during a deployment. Since recurrences are managed by a thread, won't killing the thread break schedules? Luckily, the answer to that is no.
Concurrency Controls
Let's look at one final feature of Solid Queue, namely concurrency controls. Sometimes, you want to limit how many jobs of a certain kind can run simultaneously. You can do so using Solid Queue with limits_concurrency
.
class MyJob < ApplicationJob limits_concurrency to: 1, key: ->(user) { user.id }, duration: 15.minutes, group: 'UserOnboarding' # ...
Here, we are telling SolidQueue to run a maximum of one instance of MyJob
for each user. Let's examine the configuration in more detail.
to
: The maximum number of jobs you want running concurrently.key
: A required argument to designate which jobs should be limited together. In our example, jobs with the same User ID are limited to a single concurrent execution. You may use any job arguments askey
, but constants such as strings or symbols are also allowed.duration
: The maximum time for which Solid Queue can guarantee concurrency after a job is enqueued. If your jobs run longer than that, concurrency controls will not apply and jobs may overlap. We'll learn why later!group
: You can use this option to limit concurrency across different job classes.
If you want to learn more, I refer you to the concurrency control documentation. Concurrency controls are easily Solid Queue's most sophisticated feature. If scheduled tasks didn't already make your head spin, learning how this feature works surely will.
Let's start with the basics. Like other Solid Queue features, concurrency controls are backed by various models and their corresponding database tables. Two that you need to be particularly aware of are Semaphore
and BlockedExecution
.
# lib/generators/solid_queue/install/templates/db/queue_schema.rb create_table "solid_queue_semaphores", force: :cascade do |t| t.string "key", null: false t.integer "value", default: 1, null: false t.datetime "expires_at", null: false # ... end create_table "solid_queue_blocked_executions", force: :cascade do |t| t.bigint "job_id", null: false t.string "queue_name", null: false t.integer "priority", default: 0, null: false t.string "concurrency_key", null: false t.datetime "expires_at", null: false # ... end
Let's look at Semaphore
first. As the name suggests, this is an implementation of the counting semaphore pattern. Whenever Solid Queue enqueues a job with limits_concurrency
, it first tries to acquire a semaphore lock based on a concurrency key. This concurrency key is based on the arguments passed to limits_concurrency
, namely the job class, the key, and the group name — if any was provided. If the semaphore is available, the job is enqueued. If it is not, a BlockedExecution
record is created instead.
Semaphores have a value
to support multiple concurrent jobs. You can think of it as the remaining capacity of the semaphore. Acquiring a semaphore means decrementing that value, and releasing it means incrementing it. A semaphore is considered unavailable once its value is zero. Let's look at an example of how the locking mechanism works for a simple job.
class MyJob < ApplicationJob # Only three jobs of 'MyJob' should run at the same time limits_concurrency to: 3, key: -> { 'MyJob' }, duration: 15.minutes # ... end
Let's see what happens if we try to enqueue this job multiple times in succession.
- The first instance of
MyJob
is enqueued. There is no semaphore yet, so one is created. Its initial value islimit - 1
. Because your limit is three, the initial value of the semaphore is two. - The second instance of
MyJob
is enqueued. Solid Queue attempts to acquire a lock for that job. The job can be enqueued because the value is two, which is greater than zero. The value of the semaphore is decremented to one. - A third instance of our job is enqueued. We repeat the same procedure as before. The value of the semaphore is now zero.
- A fourth instance of
MyJob
is enqueued. Acquiring the semaphore fails because its value is now zero. ABlockedExecution
record is created for the job. - The first instance of our job finishes. When it finishes, it releases the semaphore, so the semaphore value is once again one.
- On finishing, the first job instance also calls a method to release any blocked jobs.
- The fourth instance of
MyJob
is released and again tries to acquire a lock. The semaphore value is one, so the lock can be acquired and the blocked job queued. The semaphore value is now zero.
The code for releasing a semaphore when a job finishes is straightforward.
# app/models/solid_queue/claimed_execution.rb def perform result = execute # ... ensure # This method releases the Semaphore and makes it available for the next blocked job. job.unblock_next_blocked_job end
There is one more detail that we haven't touched on yet. Why do semaphores have an expiry date, and why do we need to set a duration when using limits_concurrency
?
Let's consider what happens when a job crashes without releasing its semaphore — for example, when a worker processing that job dies. Unless we add some mechanism to clean up semaphores, the lock held by that job will be retained forever. In the worst case, this would forever block other jobs from being processed.
Semaphores have an expiry that corresponds to the duration given in the job definition to avoid that situation. If a semaphore expires — which happens if no jobs are enqueued — the semaphore will be destroyed. We already know the process responsible for that — it's our friend, the Dispatcher. It instantiates the ConcurrencyMaintenance
class, which does two things:
- First, it removes any expired semaphores.
- Second, it will check if there are any blocked jobs and release them.
Jobs are released one by one, so concurrency limits will still hold. Consider, however, what happens if your job runs longer than the given duration. In that case, the semaphore will be cleaned up, although the job will still run. If another job is then enqueued, those jobs will overlap.
Monitoring Solid Queue for Rails with AppSignal
As we've established, Solid Queue can do a lot. However, with all these moving parts, monitoring becomes crucial. Luckily, AppSignal provides built-in support for Solid Queue, with ready-made dashboards for job execution times, throughput, and failure rates. Simply install AppSignal in your Rails application, and you're good to go.
AppSignal will automatically detect your usage of Solid Queue and create an active job dashboard that contains graphs for important metrics, such as error rate and throughput.

If you ever see jobs that misbehave — either because they run slowly or have too many errors — assign them a status and assignee to effectively resolve the issue.

Obviously, you shouldn't have to look at dashboards all day to figure out if there is a problem. AppSignal Alerts has your back. Simply create a new alert for job metrics, such as failure rate and job duration, and you're all set.

Solid Queue is amazing for adding powerful job processing to your application without hassle. AppSignal does the same when it comes to monitoring!
Wrapping Up
We've covered a lot of ground in our exploration of Solid Queue's advanced features. From scheduled jobs to complex dependency chains, each feature builds on the solid foundation we discussed in part one. As we've seen, it's not easy to build a job processing backend. But by diving deep into the Solid Queue source code and its workings, we've gained an understanding and some appreciation for the challenges involved.
In any case, Solid Queue is a wonderful addition to the Rails ecosystem, due to its excellent database design and process coordination. It provides the tools you need while maintaining its core promise: simplicity and reliability, without external dependencies.
Happy coding!
Wondering what you can do next?
Finished this article? Here are a few more things you can do:
- Subscribe to our Ruby Magic newsletter and never miss an article again.
- Start monitoring your Ruby app with AppSignal.
- Share this article on social media
Most popular Ruby articles
What's New in Ruby on Rails 8
Let's explore everything that Rails 8 has to offer.
See moreMeasuring the Impact of Feature Flags in Ruby on Rails with AppSignal
We'll set up feature flags in a Solidus storefront using Flipper and AppSignal's custom metrics.
See moreFive Things to Avoid in Ruby
We'll dive into five common Ruby mistakes and see how we can combat them.
See more

Hans-Jörg Schnedlitz
Our guest author Hans is a Rails engineer from Vienna, Austria. He spends most of his time coding or reading about coding, and sometimes even writes about it on his blog! When he's not sitting in front of a screen, you'll probably find him outside, climbing some mountain.
All articles by Hans-Jörg SchnedlitzBecome our next author!
AppSignal monitors your apps
AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!
