resque-fair-share
Requires Resque >= 2.0
Partition-level queue fairness for Resque.
A Resque plugin that implements weighted fair-share scheduling at the worker level. Instead of FIFO processing, workers rotate through partitions (accounts, tenants, teams, etc.) proportionally so that high-volume partitions don't starve smaller ones (inspired by Weighted Fair Queuing).
Problem
A single large account enqueues thousands of jobs and occupies the queue for hours while smaller accounts wait. Standard FIFO processing means whoever enqueues first gets processed first, regardless of how many jobs they already have in flight.
How it works
- On enqueue: routes the job into a per-partition sub-queue and registers the partition in a priority ZSET (scored by in-flight count). Also increments a pending counter in Redis.
-
On dequeue (worker-side): a Lua script runs atomically that:
- Queries the ZSET for the lowest-scored eligible partition via
ZRANGEBYSCORE ... LIMIT 0 1(O(log k)) - LPOPs from that partition's sub-queue (O(1))
- Removes the item from the main Resque queue for consistency
- Queries the ZSET for the lowest-scored eligible partition via
- On perform: transitions the job from pending to in-flight, and back down on completion or failure
-
Hard cap: configurable
max_in_flight(default: 50 concurrent jobs per partition across all workers). Partitions at capacity are skipped entirely during dequeue. - Observability: optional StatsD gauges of per-partition queue depth
The queue always drains at full speed. No jobs are artificially delayed or moved to holding queues.
Unlike a scan-window approach, the sub-queue strategy always sees all active partitions regardless of how many jobs one tenant has enqueued. A single tenant flooding the queue cannot starve others.
Why not resque-restriction?
resque-restriction pauses excess jobs into a holding queue and releases them on a timer. Fair-share instead interleaves partitions dynamically. High-volume partitions still process, just not at the expense of everyone else.
Installation
Add to your Gemfile:
gem 'resque-fair-share'Configuration
Resque::FairShare.configure do |config|
config.dequeue_strategy = :sub_queues # :sub_queues (default) or :legacy_scan
config.max_in_flight = 50 # hard cap per partition across all workers
config.partition_key = :account_id # default key to look up in job args
config.statsd_client = MyApp.statsd # optional, must respond to #gauge
config.scan_size = 100 # legacy_scan only: queue items to examine
endUsage
Basic (hash argument with a known key)
The simplest case. Your job receives a hash containing the partition identifier:
class ProcessRecordsJob
extend Resque::Plugins::FairShare
@queue = :default
fair_share_on :account_id
def self.perform(params)
account_id = params['account_id']
# ...
end
end
Resque.enqueue(ProcessRecordsJob, account_id: 42, record_ids: [1, 2, 3])Positional arguments (block form)
When the partition value isn't in a hash, use a block to extract it:
class SyncTenantJob
extend Resque::Plugins::FairShare
@queue = :sync
fair_share_on { |args| args[0] }
def self.perform(tenant_id, options)
# ...
end
end
Resque.enqueue(SyncTenantJob, 'tenant_123', full: true)Note: with the default sub_queues strategy, the block form works fully for both enqueue and dequeue (partition routing is resolved in Ruby at enqueue time). With legacy_scan, the Lua script can only do hash-key lookup, so ensure the partition value is also present as a key in a hash argument.
Falling back to global config
If you don't call fair_share_on, the plugin uses config.partition_key (default: :account_id) and searches all hash arguments for that key.
class SimpleJob
extend Resque::Plugins::FairShare
@queue = :default
def self.perform(params)
# ...
end
end
Resque::FairShare.configure { |c| c.partition_key = :org_id }
Resque.enqueue(SimpleJob, org_id: 7, data: 'hello')Redis keys
The plugin maintains these keys in Redis (under the resque: namespace):
| Key | Type | Purpose |
|---|---|---|
fair_share:{queue}:{value}:pending |
STRING | Jobs enqueued but not yet picked up |
fair_share:{queue}:{value}:in_flight |
STRING | Jobs currently being processed |
fair_share:{queue}:sub:{value} |
LIST | Per-partition sub-queue (sub_queues strategy) |
fair_share:{queue}:partitions |
ZSET | Active partitions scored by in_flight count (sub_queues strategy) |
StatsD metrics
When config.statsd_client is set, the plugin emits gauges after each counter mutation:
resque.fair_share.{queue}.{partition_value}.pendingresque.fair_share.{queue}.{partition_value}.in_flight
Any object responding to #gauge(name, value) works (Datadog's dogstatsd-ruby, statsd-instrument, etc.).
Performance
The sub-queue dequeue runs atomically inside Redis. Partition selection is O(log k) via a ZSET scored by in-flight count. The LPOP from the chosen sub-queue is O(1). This scales to high-cardinality partition keys (100k+ unique tenants) without degradation.
Dequeue overhead vs plain Resque
| Scenario | Plain avg | Fair-share avg | Overhead |
|---|---|---|---|
| 1k jobs / 1 tenant (FIFO-equivalent) | 0.23ms | 0.25ms | ~6% |
| 1k jobs / 10 tenants | 0.24ms | 0.25ms | ~6% |
| 1k jobs / 100 tenants | 0.25ms | 0.26ms | ~4% |
| 10k jobs / 10 tenants | 0.23ms | 0.26ms | ~12% |
| 10k jobs / 100 tenants | 0.20ms | 0.25ms | ~24% |
| 10k jobs / 1000 tenants | 0.24ms | 0.30ms | ~25% |
| 100k jobs / 100 tenants | 0.22ms | 0.35ms | ~58% |
| 100k jobs / 1000 tenants | 0.23ms | 0.70ms | ~199% |
The overhead grows with queue depth because of the LREM on the main queue (kept for resque-web compatibility). In absolute terms, even the worst case (100k items) stays under 1ms per dequeue.
Skewed and saturated scenarios
| Scenario | Plain avg | Fair-share avg | Overhead |
|---|---|---|---|
| 10k jobs / 100 tenants (90% from 1 tenant) | 0.24ms | 0.24ms | ~0% |
| 100k jobs / 1000 tenants (99% from 1 tenant) | 0.21ms | 0.22ms | ~1% |
| 10k jobs / 100 tenants (80% saturated) | 0.17ms | 0.28ms | ~61% |
When one tenant dominates, the ZSET picks the correct (underserved) partition in O(log k) with no scan. The plain Resque LPOP picks the dominant tenant's job every time (fast but unfair).
Enqueue overhead
| Scenario | Plain avg | Fair-share avg | Overhead |
|---|---|---|---|
| 500 jobs / 1 tenant | 0.22ms | 0.62ms | ~186% |
| 500 jobs / 10 tenants | 0.20ms | 0.65ms | ~222% |
| 500 jobs / 100 tenants | 0.25ms | 0.59ms | ~133% |
| 1000 jobs / 500 tenants | 0.21ms | 0.62ms | ~195% |
The fair-share enqueue does 3 Redis operations per job (RPUSH to main queue, RPUSH to sub-queue, ZADD NX to ZSET) plus an INCR for the pending counter. The constant ~0.4ms additional cost is independent of queue depth or partition cardinality.
Redis memory overhead
| Scenario | Plain | Fair-share | Ratio |
|---|---|---|---|
| 1k jobs / 10 tenants | 47 KB | 96 KB | 2.0x |
| 1k jobs / 100 tenants | 48 KB | 115 KB | 2.4x |
| 10k jobs / 100 tenants | 481 KB | 978 KB | 2.0x |
| 10k jobs / 1000 tenants | 490 KB | 1269 KB | 2.6x |
Memory overhead is ~2x from dual-writing job payloads to both the main queue and sub-queues. The ZSET and counter keys add negligible overhead. At 10k jobs with 1000 tenants, the total fair-share footprint is ~1.2 MB.
Running benchmarks
bundle exec rspec --tag benchmarkMigrating from legacy_scan
If you have existing jobs in the queue that were enqueued before the sub-queue strategy was enabled, run the migration to backfill sub-queues:
Resque::FairShare::Migration.perform('default')This reads the main queue in batches, extracts partition values, and populates the sub-queues. It's safe to run while workers are active (new jobs are dual-written by the hooks). You can also roll back to the legacy strategy at any time:
Resque::FairShare.configure { |c| c.dequeue_strategy = :legacy_scan }Development
docker compose up -d # starts Redis on port 6380
bundle install
bundle exec rspec # runs unit + integration tests
bundle exec rubocop # lintingOr point to an existing Redis:
REDIS_URL=redis://localhost:$PORT/15 bundle exec rspecLicense
MIT