sidekiq-fairplay
Note
This gem is a reference implementation of the approach I describe in my EuRuKo 2025 talk “Prioritization justice: lessons from making background jobs fair at scale”. While the approach itself is battle-tested in production in a real multi-tenant app with lots of users, the gem is not (yet). So, use at your own peril 🫣
Are you sure you're treating your users fairly? Some of them could be stuck in the queue while a greedy user monopolizes your workers! 😱
sidekiq‑fairplay
implements fair background job prioritization for Sidekiq. Instead of letting a single noisy tenant hog your queues, it enqueues jobs in balanced rounds, using dynamically calculated tenant weights. It works especially well in multi‑tenant apps, where you want fairness even when some tenants are "needier" than others.
To understand how it works, take a look at the example below:
class HeavyJob
include Sidekiq::Job
include Sidekiq::Fairplay::Job
# This intercepts all jobs you enqueue and gradually releases them into the main queue
# in batches of 100 jobs per minute, while ensuring that no user is left behind.
sidekiq_fairplay_options(
enqueue_interval: 1.minute,
enqueue_jobs: 100,
tenant_key: ->(user_id, _foo) { user_id }
)
def perform(user_id, foo)
# do heavy work
end
end
Tip
Looking for more background, alternative strategies, or further reading? Check out the RESOURCES.md file for additional articles, gems, and research on fair prioritization, shuffle-sharding, throttling, and more.
Requirements
- Ruby >= 3.4
- Sidekiq >= 7
Installation
Add to your Gemfile and bundle:
gem 'sidekiq-fairplay'
Configure the client middleware on both client and server:
Sidekiq.configure_client do |config|
config.client_middleware do |chain|
chain.prepend Sidekiq::Fairplay::Middleware
end
end
Sidekiq.configure_server do |config|
config.client_middleware do |chain|
chain.prepend Sidekiq::Fairplay::Middleware
end
end
Important
It's best to insert the middleware at the start of the chain using the #prepend
method, as shown above. This is important because Sidekiq::Fairplay::Middleware
runs twice: first when you attempt to enqueue the job and it gets intercepted, and again when the planner actually enqueues it. If other middlewares are placed before it, this double execution can cause subtle issues.
API
In the following example you can see all of the available configuration parameters and their meaning:
class HeavyJob
include Sidekiq::Job
include Sidekiq::Fairplay::Job
sidekiq_options queue: :heavy_stuff
sidekiq_fairplay_options(
# How often the planner tries to enqueue more jobs into `heavy_stuff` (in seconds).
# It should be large enough for the planner job to finish executing in that time.
enqueue_interval: 60,
# How many jobs the planner tries to enqueue every `enqueue_interval`.
# If the jobs are processed faster than the planner enqueues them, increase this number.
enqueue_jobs: 100,
# Tenant ID extraction from the job arguments. It's required and it should return a string.
# It is called in the client middleware (i.e. every time you call `SomeWorker.perform_async`).
tenant_key: ->(tenant_id, *_args) { tenant_id },
# Tenant weights extraction. It accepts a list of tenants who currently have jobs waiting to be enqueued.
# It should return a hash with keys being tenant IDs and values being their respective weights/priorities.
# It's called during the planning and it should be able to execute within `enqueue_interval`.
tenant_weights: ->(tenant_ids) { tenant_ids.to_h { |tid| [tid, 1] } }
# A *very* important parameter to control backpressure and avoid flooding the queue (in seconds).
# If the latency of `heavy_stuff` is larger than this number, the planner will skip a beat.
latency_threshold: 60,
# The queue in which the planner job should be executing.
planner_queue: 'default',
# For how long should the planner job hold the lock (in seconds).
# This is a protection against accidentally running multiple planners at the same time.
planner_lock_ttl: 60,
)
def perform(tenant_id, foo)
# do heavy work
end
end
Configuration
You can specify some of the default values in sidekiq.yml
:
fairplay:
:default_latency_threshold: 60
:default_planner_queue: default
:default_planner_lock_ttl: 60
Or directly in the code:
Sidekiq::Fairplay::Config.default_latency_threshold = 60
Sidekiq::Fairplay::Config.default_planner_queue = 'default'
Sidekiq::Fairplay::Config.default_planner_lock_ttl = 60
Sidekiq::Fairplay::Config.default_tenant_weights = ->(tenant_ids) { tenant_ids.to_h { |tid| [tid, 1] } }
How it works
At a high level, sidekiq-fairplay
introduces virtual per-tenant queues. Instead of enqueuing jobs directly into Sidekiq, each job first goes into its tenant's queue. Then, at regular intervals, a special planner job (Sidekiq::Fairplay::Planner
) runs. The planner decides which jobs to promote from tenant queues into the main Sidekiq queue—while keeping things fair.
Backpressure
Without backpressure, we’d just dump all jobs from tenant queues into the main queue and end up back at square one (high latency and unhappy users). To avoid that, the planner checks queue latency before enqueuing. If latency is already high, it waits. This ensures that new tenants arriving later still get a chance to have their jobs processed, even if older tenants are sitting on mountains of unprocessed work.
Dynamic weights
We keep track of how many jobs are waiting to be enqueued for each tenant. Only tenants with pending work are passed to your tenant_weights
callback, so your calculations can stay efficient. The callback returns weights: larger numbers mean more jobs get promoted to the main queue. So, weight 10
> weight 1
(just like Sidekiq’s built-in queue weights).
From there, you can apply your own prioritization logic—for example:
- Favor paying customers over freeloaders.
- Cool down tenants who've just had a large batch processed.
- Balance "needy" vs. "quiet" tenants.
Reliability
All operations—pushing jobs into tenant queues, pulling them out—are performed atomically in Redis using Lua scripts. This guarantees consistent state with a single round-trip. However, if a network failure or process crash happens after a job is enqueued into the main queue but before it’s dropped from its tenant queue, that job may be processed twice. In other words, sidekiq-fairplay
provides at-least-once delivery semantics.
Concurrency
We use two simple Redis-backed distributed locks:
-
Planner deduplication lock
- Ensures only one planner per job class is enqueued within
enqueue_interval
. - This is needed to avoid flooding Sidekiq with duplicate jobs.
- Ensures only one planner per job class is enqueued within
-
Planner execution lock
- Ensures only one planner per job class runs at a time.
- Not strictly necessary (the first lock already prevents most issues), but adds safety.
Warning
If a planner takes longer than its planner_lock_ttl
, multiple planners may run concurrently. It's not the end of the world, but it means you probably should optimize your tenant_weights
logic and/or increase the enqueue_interval
.
Troubleshooting
- If you use Sidekiq Pro and are using
reliable_scheduler!
, then keep in mind that it bypasses the client middlewares. This essentially means that all jobs scheduled viaperform_in/perform_at
will bypass the planner and go directly into the main queue. - If you use
unique_for
, you must ensure thatSidekiq::Fairplay::Middleware
comes beforeSidekiq::Enterprise::Unique::Client
in the client middleware chain; otherwise, such jobs may lock themselves out of execution.
Development
After checking out the repo, run bin/setup
to install dependencies. To execute the test suite simply run rake
.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/baygeldin/sidekiq-fairplay.
License
The gem is available as open source under the terms of the MIT License.