How Sidekiq really works
Have you ever wondered how Sidekiq works and what happen from the moment you queue the worker and the moment it is executed? I did. I checked step by step the whole process. Meanwhile I explored many useful Redis features and saw how, one of my favourite tools that I work with for many years, is designed under the hood.
Let's just begin with something that every Rails developer that is using Sidekiq is familiarized with - a simple worker:
class MySimpleWorker
include Sidekiq::Worker
def perform(text)
puts text
end
end
So the question is, what happen between the moment of execution the below line and printing the "I was performed" text in the console:
MySimpleWorker.perform_async("I was performed!")
Adding job to the queue
When you call a worker with the perform_async
method, the worker is queued and executed asynchronously instead of being executed immediately after calling it. If you would like to execute the worker immediately, then you should call MySimpleWorker.new.perform("I was performed!")
- the code will be executed in the console without hitting the Sidekiq.
Forming the options hash
After you call the perform_async
method on a class that is extended with Sidekiq::Worker
module, a hash with options is formed. The hash consists of arguments passed to the worker and the class name:
{"args" => ["I was performed"], "class" => MySimpleWorker}
if you would chain the call with the set
method like this:
MySimpleWorker.set(queue: 'normal').perform_async("I was performed!")
then the hash from the set
method will be also merged to the queue options and our options hash will look like the following:
{ "args" => ["I was performed"], "class" => MySimpleWorker, "queue" => "normal" }
Options assignment
After the item is queued, a validation is performed to check if a valid queue was given. Besides it, default options are assigned also - it means that the default
queue will be assigned unless you specified the queue name in the worker definition or used the set
method to set the queue when calling the worker.
When validation is successful, the following attributes are assigned to the job options:
class
- a string that represents the name of the worker classqueue
- a string that represents the name of the queue in which the job should be executedjid
- a string that represents a unique id for a given job. It's generated bySecureRandom.hex(12)
callcreated_at
- the time when the job was created. It's set toTime.now.to_f
if wasn't specified in the options of the worker
Calling middleware
Before the job is pushed to the Redis, Sidekiq is executing the middleware that was configured. Middleware is the code configured to be executed before or after a message is processed. An example of middleware can be Sidekiq Uniq jobs that is verifying if a job with the same arguments is not already processed.
The middleware classes receive the same arguments that were assigned in the previous step including jid
, args
, class
, and created_at
.
Pushing data to the redis
When middleware didn't reject the job, it is the time to push the data into the Redis so it can be processed. Two actions are now possible depending on the given case. I will now describe only the case when you want to process the worker asynchronously as soon as possible.
The second case is when you want to perform the worker in at a given moment in the future. You can achieve this by calling the following code:
MySimpleWorker.perform_in(5.minutes, "I was performed!")
For now, let's assume that you want to perform the job as soon as possible. The job payload (all arguments required to perform the job successfully, assigned in previous stages) is dumped into the JSON format. Two Redis methods are called: sadd
and lpush
.
Performing sadd
Imagine that there is an array of strings, with a unique name that you can refer to, and this array is stored in Redis:
["default", "high"]
If you want to add the next element to this array, Redis verifies first if the given element is already there. If the element already exists then the addition is ignored, otherwise, the element is added at the end of the array.
In Ruby you could replicate this behavior by creating the following class:
class Redis
def initialize
@lists = {}
end
def sadd(name, item)
@lists[name] ||= []
return @lists[name] if @lists[name].include?(item)
@lists[name] << item
@lists[name]
end
end
conn = Redis.new
conn.sadd("queues", "default") # => ["default"]
conn.sadd("queues", "default") # => ["default"]
conn.sadd("queues", "high") # => ["default", "high"]
Sidekiq is using the sadd
method on the queues
list to add a queue name of the job that it is currently processing:
conn.sadd("queues", queue)
Performing lpush
Imagine that you have an array of strings and each time you add an element to it, it's pushed at the beginning. That's how the lpush
command works for in Redis.
In Ruby you could replicate this behavior by creating the following class:
class Redis
def initialize
@lists = {}
end
def lpush(name, *items)
@lists[name] ||= []
items.each do |item|
@lists[name].unshift(item)
end
end
end
Sidekiq is using lpush
method to push job to the given queue:
conn.lpush("queue:#{queue}", to_push)
Summary
When the call is successful and the job was queued, you receive the jid
value as a return value. This id is now unique identification for the job you have just pushed to the queue. The job is now queued and if you have Sidekiq running using the bundle exec sidekiq
command, it will pick it up and process it. How the process of picking up a job looks like, I will describe in the paragraph below.
Picking job from the queue
I mentioned above that your job will be picked as soon as you will execute the bundle exec sidekiq
command. But what exactly is triggered then? Let's see.
Creating one instance of CLI object
The very first thing that is invoked when the command is executed is Sidekiq::CLI.instance
method. The CLI
class is including the Singleton
module which means that by calling .instance
on a class, we ensure that only one instance will be created. You can read more about it on the official documentation: https://ruby-doc.org/stdlib-2.5.1/libdoc/singleton/rdoc/Singleton.html
You can test how the Singleton
pattern is working by using this simple snippet:
class Test
include Singleton
end
Test.instance == Test.instance # => true
it means that even if you call bundle exec sidekiq
twice in separated shell windows, the CLI
instance will be the same.
Parsing the CLI
The second thing that Sidekiq is doing after executing the start up command is parsing. There are two steps of parsing executed one by one:
Options assignment
Options passed to bundle exec sidekiq
command are parsed and assigned. So if you are passing config file as -C config/sidekiq.yml
it will be detected and the config file location will be assigned for the future usage. If the file passed as a config won't be available, an error will be raised.
Default options are set as well if you don't provide the values when starting the Sidekiq. The default options set include concurrency and queues list.
Logger assignment
The logger is set on a debug level if the verbose
option is specified. You can achieve this by calling the start up command either with -v
flag or --verbose
.
Validation
The last step in the parse phase is to validate if the value of concurrency
and timeout
options is not smaller than 0 and if Sidekiq has access to a Rails application or Ruby file. You can point Sidekiq to the Rails application config by using --require
option.
Running the machine
This phase consists of multiple smaller steps that are needed to ensure that Sidekiq can perform its tasks fine. The following steps are performed:
- If Sidekiq is executed in the development environment, the banner with kicking person is printed in the console
- Information about licensing is printed
- Upgrading to PRO version information is printed unless you are already using the PRO version
- Error is raised if the Redis version is lower than 4
- Error is raised if the Redis pool size is too small for Sidekiq (it needs the number of connections you set in
concurrency
option + 2) - Server middleware is touched so it's not lazily loaded by multiple threads
- Information about applied client and server middleware is printed
The last step is to start the launcher which is a topic for the next paragraph!
Launching
If you are running Sidekiq in the development environment, the Starting processing, hit Ctrl-C to stop
information is printed. Then the launcher is running.
The run
method from the launcher does two things: start poller and manager.
Poller
A new thread called scheduler
is called to take care of jobs that are enqueued to be performed in the future. There are two sets that are taken into the account: retry
and schedule
. Basically retry
queue works the same as scheduled
queue with one difference: the time of execution is set automatically depending on the retry number.
For each set, the zrangebyscore
method from Redis is executed. The name is weird, so what actually it does? To understand better what it does, we have to take a step back to see how are pushed to the Redis jobs that suppose to be executed later not immediately. When a job should be executed in the future, Sidekiq is calling zadd
method from Redis. This method adds an element to the list along with the score. The score in our case is the time when a given job should be executed. So if you call the following code:
MySimpleWorker.perform_in(3.minutes, "I was performed!")
then the score will be following:
Time.now.to_f + 3.minutes.to_f
So we have now some jobs and scores on the list and then we call zrangebyscore
:
now = Time.now.to_f.to_s
jobs = conn.zrangebyscore(sorted_set, "-inf", now, limit: [0, 100])
It means that we want to pull all items from the sorted_set
, which can be either schedule
or retry
queue, starting from any item in the past to now
- this way we will pull all jobs that should be performed until now.
Each job is then passed to zrem
method from Redis which removes the item from the sorted list with scores and pushed to Sidekiq just like you would call perform_async
on the worker - the process we described while ago start from the beginning.
After all jobs are processed, the process sleeps for a while (the time for the process waits is called random pool interval) and then it's executed again.
Manager
Besides poller, there is a manager which takes care of processing jobs. Depending on the concurrency setting, it runs the defined number of workers. Each worker then picks one job from the specified queues using brpop
method from Redis. This method receives a list of lists names and picks one element from the tail if the list is not empty. If there are no elements to pick, it blocks the connection.
When the job is picked, the following steps are performed:
- job details are decoded and if a job has a wrong format, it is immediately added to the dead queue (it means it won't be automatically retried)
- middleware is invoked
- the job is performed just like you would call
MySimpleWorker.new.perform
If the error is raised when a job is performed, logs are printed in the console along with the error backtrace.
Sidekiq dashboard
We just went through the complete process of queueing jobs and processing them but can't omit the dashboard as it is also an important part of the Sidekiq. The dashboard is built on the top of the Rack and it is just a simple application with a few routes defined where the data is displayed.
Views of the dashboard are just templates in the .erb
format where Ruby code is rendered among HTML tags.
Summary
After reading the article you may think that the Sidekiq architecture is quite simple but in fact, the massive amount of work was done to make it a stable and reliable software for processing tons of workers.
After many years of working with Sidekiq, I still appreciate the way it works, the fact that it solves many problems, and improves the performance of the application. I hope you found it useful and fun to go through this guide.