The project is in a healthy, maintained state
A Resque plugin that implements weighted fair-share scheduling at the worker level. Instead of FIFO processing, workers rotate through accounts proportionally so that high-volume accounts don't starve smaller ones.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

>= 4.0
>= 2.0
 Project Readme

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:
    1. Queries the ZSET for the lowest-scored eligible partition via ZRANGEBYSCORE ... LIMIT 0 1 (O(log k))
    2. LPOPs from that partition's sub-queue (O(1))
    3. Removes the item from the main Resque queue for consistency
  • 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
end

Usage

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}.pending
  • resque.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 benchmark

Migrating 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    # linting

Or point to an existing Redis:

REDIS_URL=redis://localhost:$PORT/15 bundle exec rspec

License

MIT