resque-fair-share 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 620b34d14c123d949f93b7df791906a6523a33cc593a6b4aac952ef2a5023d2a
4
+ data.tar.gz: 47c6b05a2167fd496a99ef0f1ccf30c45a9339f866ac4e838f8d9c2ff22986f7
5
+ SHA512:
6
+ metadata.gz: df5fe8d9683f859b351e9265609a4fbb86c11a3a43ceb79cce4eb6c293cda9c851a7abb8f7cc1486d2388b2c29dded84b5702cd3d5fd07c17f2cfbd9958fe7a4
7
+ data.tar.gz: 4d3a14190919a666aadadd7a5c0c4bb9a2189dcbf57086ac6cf1a7b812222ad3c1d2785e92ef7469addfbeee29760c1406f39c1d49b22a2fe68de5881bfcc3f0
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ - Initial project scaffolding
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Duarte Reis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,222 @@
1
+ # resque-fair-share
2
+
3
+ **Requires Resque >= 2.0**
4
+
5
+ Partition-level queue fairness for Resque.
6
+
7
+ 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).
8
+
9
+ ## Problem
10
+
11
+ 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.
12
+
13
+ ## How it works
14
+
15
+ - **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.
16
+ - **On dequeue (worker-side)**: a Lua script runs atomically that:
17
+ 1. Queries the ZSET for the lowest-scored eligible partition via `ZRANGEBYSCORE ... LIMIT 0 1` (O(log k))
18
+ 2. LPOPs from that partition's sub-queue (O(1))
19
+ 3. Removes the item from the main Resque queue for consistency
20
+ - **On perform**: transitions the job from pending to in-flight, and back down on completion or failure
21
+ - **Hard cap**: configurable `max_in_flight` (default: 50 concurrent jobs per partition across all workers). Partitions at capacity are skipped entirely during dequeue.
22
+ - **Observability**: optional StatsD gauges of per-partition queue depth
23
+
24
+ The queue always drains at full speed. No jobs are artificially delayed or moved to holding queues.
25
+
26
+ 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.
27
+
28
+ ## Why not resque-restriction?
29
+
30
+ `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.
31
+
32
+ ## Installation
33
+
34
+ Add to your Gemfile:
35
+
36
+ ```ruby
37
+ gem 'resque-fair-share'
38
+ ```
39
+
40
+ ## Configuration
41
+
42
+ ```ruby
43
+ Resque::FairShare.configure do |config|
44
+ config.dequeue_strategy = :sub_queues # :sub_queues (default) or :legacy_scan
45
+ config.max_in_flight = 50 # hard cap per partition across all workers
46
+ config.partition_key = :account_id # default key to look up in job args
47
+ config.statsd_client = MyApp.statsd # optional, must respond to #gauge
48
+ config.scan_size = 100 # legacy_scan only: queue items to examine
49
+ end
50
+ ```
51
+
52
+ ## Usage
53
+
54
+ ### Basic (hash argument with a known key)
55
+
56
+ The simplest case. Your job receives a hash containing the partition identifier:
57
+
58
+ ```ruby
59
+ class ProcessRecordsJob
60
+ extend Resque::Plugins::FairShare
61
+
62
+ @queue = :default
63
+ fair_share_on :account_id
64
+
65
+ def self.perform(params)
66
+ account_id = params['account_id']
67
+ # ...
68
+ end
69
+ end
70
+
71
+ Resque.enqueue(ProcessRecordsJob, account_id: 42, record_ids: [1, 2, 3])
72
+ ```
73
+
74
+ ### Positional arguments (block form)
75
+
76
+ When the partition value isn't in a hash, use a block to extract it:
77
+
78
+ ```ruby
79
+ class SyncTenantJob
80
+ extend Resque::Plugins::FairShare
81
+
82
+ @queue = :sync
83
+ fair_share_on { |args| args[0] }
84
+
85
+ def self.perform(tenant_id, options)
86
+ # ...
87
+ end
88
+ end
89
+
90
+ Resque.enqueue(SyncTenantJob, 'tenant_123', full: true)
91
+ ```
92
+
93
+ **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.
94
+
95
+ ### Falling back to global config
96
+
97
+ 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.
98
+
99
+ ```ruby
100
+ class SimpleJob
101
+ extend Resque::Plugins::FairShare
102
+
103
+ @queue = :default
104
+
105
+ def self.perform(params)
106
+ # ...
107
+ end
108
+ end
109
+
110
+ Resque::FairShare.configure { |c| c.partition_key = :org_id }
111
+ Resque.enqueue(SimpleJob, org_id: 7, data: 'hello')
112
+ ```
113
+
114
+ ## Redis keys
115
+
116
+ The plugin maintains these keys in Redis (under the `resque:` namespace):
117
+
118
+ | Key | Type | Purpose |
119
+ |-----|------|---------|
120
+ | `fair_share:{queue}:{value}:pending` | STRING | Jobs enqueued but not yet picked up |
121
+ | `fair_share:{queue}:{value}:in_flight` | STRING | Jobs currently being processed |
122
+ | `fair_share:{queue}:sub:{value}` | LIST | Per-partition sub-queue (sub_queues strategy) |
123
+ | `fair_share:{queue}:partitions` | ZSET | Active partitions scored by in_flight count (sub_queues strategy) |
124
+
125
+ ## StatsD metrics
126
+
127
+ When `config.statsd_client` is set, the plugin emits gauges after each counter mutation:
128
+
129
+ - `resque.fair_share.{queue}.{partition_value}.pending`
130
+ - `resque.fair_share.{queue}.{partition_value}.in_flight`
131
+
132
+ Any object responding to `#gauge(name, value)` works (Datadog's `dogstatsd-ruby`, `statsd-instrument`, etc.).
133
+
134
+ ## Performance
135
+
136
+ 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.
137
+
138
+ ### Dequeue overhead vs plain Resque
139
+
140
+ | Scenario | Plain avg | Fair-share avg | Overhead |
141
+ |----------|-----------|----------------|----------|
142
+ | 1k jobs / 1 tenant (FIFO-equivalent) | 0.23ms | 0.25ms | ~6% |
143
+ | 1k jobs / 10 tenants | 0.24ms | 0.25ms | ~6% |
144
+ | 1k jobs / 100 tenants | 0.25ms | 0.26ms | ~4% |
145
+ | 10k jobs / 10 tenants | 0.23ms | 0.26ms | ~12% |
146
+ | 10k jobs / 100 tenants | 0.20ms | 0.25ms | ~24% |
147
+ | 10k jobs / 1000 tenants | 0.24ms | 0.30ms | ~25% |
148
+ | 100k jobs / 100 tenants | 0.22ms | 0.35ms | ~58% |
149
+ | 100k jobs / 1000 tenants | 0.23ms | 0.70ms | ~199% |
150
+
151
+ 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.
152
+
153
+ ### Skewed and saturated scenarios
154
+
155
+ | Scenario | Plain avg | Fair-share avg | Overhead |
156
+ |----------|-----------|----------------|----------|
157
+ | 10k jobs / 100 tenants (90% from 1 tenant) | 0.24ms | 0.24ms | ~0% |
158
+ | 100k jobs / 1000 tenants (99% from 1 tenant) | 0.21ms | 0.22ms | ~1% |
159
+ | 10k jobs / 100 tenants (80% saturated) | 0.17ms | 0.28ms | ~61% |
160
+
161
+ 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).
162
+
163
+ ### Enqueue overhead
164
+
165
+ | Scenario | Plain avg | Fair-share avg | Overhead |
166
+ |----------|-----------|----------------|----------|
167
+ | 500 jobs / 1 tenant | 0.22ms | 0.62ms | ~186% |
168
+ | 500 jobs / 10 tenants | 0.20ms | 0.65ms | ~222% |
169
+ | 500 jobs / 100 tenants | 0.25ms | 0.59ms | ~133% |
170
+ | 1000 jobs / 500 tenants | 0.21ms | 0.62ms | ~195% |
171
+
172
+ 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.
173
+
174
+ ### Redis memory overhead
175
+
176
+ | Scenario | Plain | Fair-share | Ratio |
177
+ |----------|-------|------------|-------|
178
+ | 1k jobs / 10 tenants | 47 KB | 96 KB | 2.0x |
179
+ | 1k jobs / 100 tenants | 48 KB | 115 KB | 2.4x |
180
+ | 10k jobs / 100 tenants | 481 KB | 978 KB | 2.0x |
181
+ | 10k jobs / 1000 tenants | 490 KB | 1269 KB | 2.6x |
182
+
183
+ 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.
184
+
185
+ ### Running benchmarks
186
+
187
+ ```bash
188
+ bundle exec rspec --tag benchmark
189
+ ```
190
+
191
+ ## Migrating from legacy_scan
192
+
193
+ 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:
194
+
195
+ ```ruby
196
+ Resque::FairShare::Migration.perform('default')
197
+ ```
198
+
199
+ 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:
200
+
201
+ ```ruby
202
+ Resque::FairShare.configure { |c| c.dequeue_strategy = :legacy_scan }
203
+ ```
204
+
205
+ ## Development
206
+
207
+ ```bash
208
+ docker compose up -d # starts Redis on port 6380
209
+ bundle install
210
+ bundle exec rspec # runs unit + integration tests
211
+ bundle exec rubocop # linting
212
+ ```
213
+
214
+ Or point to an existing Redis:
215
+
216
+ ```bash
217
+ REDIS_URL=redis://localhost:$PORT/15 bundle exec rspec
218
+ ```
219
+
220
+ ## License
221
+
222
+ MIT
@@ -0,0 +1,32 @@
1
+ module Resque
2
+ module FairShare
3
+ class Configuration
4
+ # @return [Symbol] Dequeue strategy (:sub_queues or :legacy_scan).
5
+ attr_accessor :dequeue_strategy
6
+
7
+ # @return [Integer] Number of queue items the Lua script examines per dequeue (legacy_scan only).
8
+ attr_accessor :scan_size
9
+
10
+ # @return [Integer] Hard cap on concurrent jobs per partition across all workers.
11
+ attr_accessor :max_in_flight
12
+
13
+ # @return [Symbol] Job argument key used to identify partitions (e.g. :account_id, :tenant_id).
14
+ attr_accessor :partition_key
15
+
16
+ # @return [Integer, nil] Zero-based index into the job args array for positional partition extraction.
17
+ attr_accessor :partition_index
18
+
19
+ # @return [#gauge, nil] Optional StatsD-compatible client for emitting per-partition depth gauges.
20
+ attr_accessor :statsd_client
21
+
22
+ def initialize
23
+ @dequeue_strategy = :sub_queues
24
+ @scan_size = 100
25
+ @max_in_flight = 50
26
+ @partition_key = :account_id
27
+ @partition_index = nil
28
+ @statsd_client = nil
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,54 @@
1
+ module Resque
2
+ module FairShare
3
+ class Dequeue
4
+ LEGACY_SCRIPT = File.read(File.expand_path('../../../lua/fair_dequeue.lua', __dir__))
5
+ SUB_QUEUE_SCRIPT = File.read(File.expand_path('../../../lua/fair_dequeue_v2.lua', __dir__))
6
+
7
+ def self.perform(queue)
8
+ config = Resque::FairShare.configuration
9
+
10
+ if config.dequeue_strategy == :sub_queues
11
+ perform_sub_queues(queue, config)
12
+ else
13
+ perform_legacy(queue, config)
14
+ end
15
+ end
16
+
17
+ def self.perform_sub_queues(queue, config)
18
+ namespace = Resque.redis.namespace
19
+ partitions_key = "#{namespace}:fair_share:#{queue}:partitions"
20
+ queue_key = "#{namespace}:queue:#{queue}"
21
+ sub_prefix = "#{namespace}:fair_share:#{queue}:sub:"
22
+
23
+ result = Resque.redis.redis.eval(
24
+ SUB_QUEUE_SCRIPT,
25
+ keys: [partitions_key, queue_key],
26
+ argv: [sub_prefix, config.max_in_flight]
27
+ )
28
+
29
+ return unless result
30
+
31
+ JSON.parse(result)
32
+ end
33
+
34
+ def self.perform_legacy(queue, config)
35
+ namespace = Resque.redis.namespace
36
+ queue_key = "#{namespace}:queue:#{queue}"
37
+ prefix = "#{namespace}:fair_share:#{queue}"
38
+ partition_index = config.partition_index ? (config.partition_index + 1).to_s : '0'
39
+
40
+ result = Resque.redis.redis.eval(
41
+ LEGACY_SCRIPT,
42
+ keys: [queue_key],
43
+ argv: [config.scan_size, config.partition_key.to_s, prefix, config.max_in_flight, partition_index]
44
+ )
45
+
46
+ return unless result
47
+
48
+ JSON.parse(result)
49
+ end
50
+
51
+ private_class_method :perform_sub_queues, :perform_legacy
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,8 @@
1
+ require_relative '../plugins/fair_share'
2
+
3
+ module Resque
4
+ module FairShare
5
+ # @deprecated Use {Resque::Plugins::FairShare} directly.
6
+ Hooks = Resque::Plugins::FairShare
7
+ end
8
+ end
@@ -0,0 +1,56 @@
1
+ module Resque
2
+ module FairShare
3
+ # Backfills per-partition sub-queues from the existing main Resque queue.
4
+ # Safe to run while workers are active (new jobs are dual-written by hooks).
5
+ module Migration
6
+ BATCH_SIZE = 500
7
+
8
+ def self.perform(queue, partition_key: nil)
9
+ key = partition_key || Resque::FairShare.configuration.partition_key
10
+ namespace = Resque.redis.namespace
11
+ queue_key = "#{namespace}:queue:#{queue}"
12
+ raw_redis = Resque.redis.redis
13
+
14
+ offset = 0
15
+ migrated = 0
16
+
17
+ loop do
18
+ items = raw_redis.lrange(queue_key, offset, offset + BATCH_SIZE - 1)
19
+ break if items.empty?
20
+
21
+ migrated += migrate_batch(items, queue, key, namespace, raw_redis)
22
+ offset += BATCH_SIZE
23
+ end
24
+
25
+ migrated
26
+ end
27
+
28
+ def self.migrate_batch(items, queue, key, namespace, raw_redis)
29
+ count = 0
30
+ items.each do |item|
31
+ partition_value = extract_value(JSON.parse(item)['args'], key)
32
+ next unless partition_value
33
+
34
+ sub_key = "#{namespace}:fair_share:#{queue}:sub:#{partition_value}"
35
+ partitions_key = "#{namespace}:fair_share:#{queue}:partitions"
36
+
37
+ raw_redis.rpush(sub_key, item)
38
+ raw_redis.zadd(partitions_key, 0, partition_value.to_s, nx: true)
39
+ count += 1
40
+ end
41
+ count
42
+ end
43
+
44
+ def self.extract_value(args, key)
45
+ return nil unless args.is_a?(Array)
46
+
47
+ hash = args.find { |a| a.is_a?(Hash) }
48
+ return nil unless hash
49
+
50
+ hash[key.to_s] || hash[key.to_sym]
51
+ end
52
+
53
+ private_class_method :migrate_batch, :extract_value
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,28 @@
1
+ module Resque
2
+ module FairShare
3
+ # Overrides Resque::Job.reserve to use fair-share dequeuing instead of LPOP.
4
+ # Prepended onto Resque::Job's singleton class on require.
5
+ module Reserve
6
+ def reserve(queue)
7
+ if fair_share_active?(queue)
8
+ payload = Resque::FairShare::Dequeue.perform(queue)
9
+ return new(queue, payload) if payload
10
+
11
+ nil
12
+ else
13
+ super
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def fair_share_active?(queue)
20
+ namespace = Resque.redis.namespace
21
+ partitions_key = "#{namespace}:fair_share:#{queue}:partitions"
22
+ Resque.redis.redis.exists?(partitions_key)
23
+ end
24
+ end
25
+ end
26
+ end
27
+
28
+ Resque::Job.singleton_class.prepend(Resque::FairShare::Reserve)
@@ -0,0 +1,5 @@
1
+ module Resque
2
+ module FairShare
3
+ VERSION = '0.1.0'.freeze
4
+ end
5
+ end
@@ -0,0 +1,26 @@
1
+ require 'resque'
2
+ require_relative 'fair_share/version'
3
+ require_relative 'fair_share/configuration'
4
+ require_relative 'plugins/fair_share'
5
+ require_relative 'fair_share/hooks'
6
+ require_relative 'fair_share/dequeue'
7
+ require_relative 'fair_share/reserve'
8
+ require_relative 'fair_share/migration'
9
+
10
+ module Resque
11
+ module FairShare
12
+ class << self
13
+ def configuration
14
+ @configuration ||= Configuration.new
15
+ end
16
+
17
+ def configure
18
+ yield(configuration)
19
+ end
20
+
21
+ def reset_configuration!
22
+ @configuration = Configuration.new
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,141 @@
1
+ module Resque
2
+ module Plugins
3
+ # Resque job hooks for fair-share scheduling.
4
+ # Extend this module in your job class to track per-partition pending and
5
+ # in-flight counters in Redis.
6
+ #
7
+ # class MyJob
8
+ # extend Resque::Plugins::FairShare
9
+ # @queue = :default
10
+ # fair_share_on :account_id
11
+ # def self.perform(params); end
12
+ # end
13
+ module FairShare
14
+ ENQUEUE_SCRIPT = File.read(File.expand_path('../../../lua/fair_enqueue.lua', __dir__))
15
+
16
+ def fair_share_on(key = nil, &block)
17
+ if block
18
+ @_fair_share_resolver = block
19
+ elsif key
20
+ @_fair_share_key = key
21
+ end
22
+ end
23
+
24
+ def after_enqueue_fair_share(*args)
25
+ partition_value = extract_partition_value(args)
26
+
27
+ redis.incr(redis_key(partition_value, 'pending'))
28
+ emit_gauge(partition_value, 'pending')
29
+
30
+ enqueue_to_sub_queue(partition_value, args)
31
+ end
32
+
33
+ def before_perform_fair_share(*args)
34
+ partition_value = extract_partition_value(args)
35
+
36
+ in_flight_key = redis_key(partition_value, 'in_flight')
37
+ in_flight = redis.get(in_flight_key).to_i
38
+ unless in_flight < Resque::FairShare.configuration.max_in_flight
39
+ re_enqueue(partition_value, args)
40
+
41
+ raise Resque::Job::DontPerform,
42
+ "Max in-flight limit reached for partition #{partition_value}. " \
43
+ "Current: #{in_flight}, max: #{Resque::FairShare.configuration.max_in_flight}"
44
+ end
45
+
46
+ redis.incr(in_flight_key)
47
+ update_partition_score(partition_value)
48
+ emit_gauge(partition_value, 'in_flight')
49
+ redis.decr(redis_key(partition_value, 'pending'))
50
+ emit_gauge(partition_value, 'pending')
51
+ end
52
+
53
+ def after_perform_fair_share(*args)
54
+ partition_value = extract_partition_value(args)
55
+
56
+ redis.decr(redis_key(partition_value, 'in_flight'))
57
+ update_partition_score(partition_value)
58
+ emit_gauge(partition_value, 'in_flight')
59
+ end
60
+
61
+ def on_failure_fair_share(_exception, *args)
62
+ partition_value = extract_partition_value(args)
63
+
64
+ redis.decr(redis_key(partition_value, 'in_flight'))
65
+ update_partition_score(partition_value)
66
+ emit_gauge(partition_value, 'in_flight')
67
+ end
68
+
69
+ private
70
+
71
+ def fair_share_partition_key
72
+ @_fair_share_key || Resque::FairShare.configuration.partition_key
73
+ end
74
+
75
+ def redis_key(partition_value, counter_type)
76
+ "fair_share:#{@queue}:#{partition_value}:#{counter_type}"
77
+ end
78
+
79
+ def redis
80
+ Resque.redis
81
+ end
82
+
83
+ def extract_partition_value(args)
84
+ if @_fair_share_resolver
85
+ value = @_fair_share_resolver.call(args)
86
+ raise ArgumentError, 'fair_share_on block returned nil' unless value
87
+
88
+ return value
89
+ end
90
+
91
+ key = fair_share_partition_key
92
+ hash = args.find { |a| a.is_a?(Hash) }
93
+ raise ArgumentError, 'No hash argument found in job arguments' unless hash
94
+
95
+ value = hash[key.to_s] || hash[key]
96
+ raise ArgumentError, "Partition key #{key} not found in job arguments" unless value
97
+
98
+ value
99
+ end
100
+
101
+ def emit_gauge(partition_value, counter_type)
102
+ client = Resque::FairShare.configuration.statsd_client
103
+ return unless client
104
+
105
+ value = redis.get(redis_key(partition_value, counter_type)).to_i
106
+ client.gauge("resque.fair_share.#{@queue}.#{partition_value}.#{counter_type}", value)
107
+ end
108
+
109
+ def enqueue_to_sub_queue(partition_value, args)
110
+ return unless Resque::FairShare.configuration.dequeue_strategy == :sub_queues
111
+
112
+ namespace = Resque.redis.namespace
113
+ sub_key = "#{namespace}:fair_share:#{@queue}:sub:#{partition_value}"
114
+ partitions_key = "#{namespace}:fair_share:#{@queue}:partitions"
115
+ job_json = Resque.encode(class: to_s, args: args)
116
+
117
+ Resque.redis.redis.eval(
118
+ ENQUEUE_SCRIPT,
119
+ keys: [sub_key, partitions_key],
120
+ argv: [job_json, partition_value.to_s]
121
+ )
122
+ end
123
+
124
+ def update_partition_score(partition_value)
125
+ return unless Resque::FairShare.configuration.dequeue_strategy == :sub_queues
126
+
127
+ namespace = Resque.redis.namespace
128
+ partitions_key = "#{namespace}:fair_share:#{@queue}:partitions"
129
+ in_flight = redis.get(redis_key(partition_value, 'in_flight')).to_i
130
+
131
+ # Update ZSET score to reflect current in_flight count (only if member exists)
132
+ Resque.redis.redis.zadd(partitions_key, in_flight, partition_value.to_s, xx: true)
133
+ end
134
+
135
+ def re_enqueue(partition_value, args)
136
+ Resque.push(@queue.to_s, class: to_s, args: args)
137
+ enqueue_to_sub_queue(partition_value, args)
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1 @@
1
+ require 'resque/fair_share'
@@ -0,0 +1,182 @@
1
+ -- ============================================================================
2
+ -- fair_dequeue.lua
3
+ -- ============================================================================
4
+ --
5
+ -- PURPOSE:
6
+ -- Instead of Resque's default behavior (LPOP = always take the oldest job),
7
+ -- this script picks the "fairest" job from the front of the queue.
8
+ --
9
+ -- WHAT "FAIR" MEANS HERE:
10
+ -- We want to give every partition (tenant/account) a roughly equal share of
11
+ -- worker time. If tenant A has 10 jobs running and tenant B has 0 jobs running,
12
+ -- we should pick tenant B's job next, even if tenant A's jobs were enqueued first.
13
+ --
14
+ -- THE ALGORITHM:
15
+ -- 1. Look at the first N jobs in the queue (the "scan window")
16
+ -- 2. For each job, check how many jobs that partition already has running (in_flight)
17
+ -- 3. Pick the job whose partition has the LOWEST in_flight count
18
+ -- 4. Remove that job from the queue and return it
19
+ --
20
+ -- This means:
21
+ -- - Partitions with fewer running jobs get priority (they're underserved)
22
+ -- - Partitions at their max_in_flight limit are skipped entirely
23
+ -- - On a tie, the job closest to the front of the queue wins (natural FIFO)
24
+ -- - If all partitions in the scan window are at max, return nil (nothing to do)
25
+ --
26
+ -- WHY A LUA SCRIPT:
27
+ -- This needs to be atomic. Between reading the queue and removing the chosen job,
28
+ -- no other worker should be able to grab the same job. Redis executes Lua scripts
29
+ -- as a single atomic operation, so no race conditions.
30
+ --
31
+ -- ============================================================================
32
+ -- INPUTS
33
+ -- ============================================================================
34
+ --
35
+ -- KEYS[1] = The Redis key for the queue list
36
+ -- e.g. "resque:queue:default"
37
+ -- This is a Redis LIST where each element is a JSON-encoded job payload
38
+ -- like: {"class":"ProcessRecords","args":[{"tenant_id":42,"data":"..."}]}
39
+ --
40
+ -- ARGV[1] = scan_size (number, as string)
41
+ -- How many items to examine from the front of the queue.
42
+ -- Larger = fairer decisions but more CPU per dequeue.
43
+ -- Smaller = faster but might miss a better candidate further back.
44
+ -- Typical value: 100
45
+ --
46
+ -- ARGV[2] = partition_key (string)
47
+ -- The JSON field name to group jobs by, e.g. "tenant_id"
48
+ -- We look for this key inside the first hash argument of the job.
49
+ --
50
+ -- ARGV[3] = key_prefix (string)
51
+ -- The prefix for Redis counter keys, e.g. "resque:fair_share:default"
52
+ -- We append ":{partition_value}:in_flight" to look up running counts.
53
+ --
54
+ -- ARGV[4] = max_in_flight (number, as string)
55
+ -- The hard cap on concurrent jobs per partition.
56
+ -- 0 means no limit (every partition is always eligible).
57
+ --
58
+ -- ARGV[5] = partition_index (number, as string)
59
+ -- 1-based index into the job args array for positional extraction.
60
+ -- 0 means disabled (only hash-key lookup is used).
61
+ -- When set, if no hash arg contains partition_key, the script uses
62
+ -- args[partition_index] as the partition value directly.
63
+ --
64
+ -- ============================================================================
65
+ -- OUTPUT
66
+ -- ============================================================================
67
+ --
68
+ -- Returns the raw JSON string of the selected job (to be parsed by Ruby),
69
+ -- or nil if no eligible job was found.
70
+ --
71
+ -- ============================================================================
72
+
73
+ -- Parse inputs. ARGV values are always strings in Redis Lua.
74
+ local scan_size = tonumber(ARGV[1])
75
+ local partition_key = ARGV[2]
76
+ local prefix = ARGV[3]
77
+ local max_in_flight = tonumber(ARGV[4])
78
+ local partition_index = tonumber(ARGV[5])
79
+
80
+ -- ============================================================================
81
+ -- STEP 1: Read the scan window
82
+ -- ============================================================================
83
+ -- LRANGE returns elements from index 0 to scan_size-1 (inclusive on both ends).
84
+ -- If the list has fewer elements, it just returns what's available.
85
+ -- This does NOT remove anything from the list; it's a read-only peek.
86
+ local items = redis.call("LRANGE", KEYS[1], 0, scan_size - 1)
87
+
88
+ -- Nothing in the queue, nothing to do.
89
+ if #items == 0 then
90
+ return nil
91
+ end
92
+
93
+ -- ============================================================================
94
+ -- STEP 2: Score each job and find the best candidate
95
+ -- ============================================================================
96
+ -- We scan through the items and track the "best" job seen so far.
97
+ -- "Best" = belongs to the partition with the lowest in_flight count.
98
+
99
+ local best_item = nil -- The raw JSON string we'll return and remove
100
+ local best_score = nil -- The in_flight count of the best partition so far
101
+
102
+ for i, item in ipairs(items) do
103
+ -- Each `item` is a JSON string like:
104
+ -- {"class":"ProcessRecords","args":[{"tenant_id":42,"data":"hello"}]}
105
+
106
+ -- Decode it into a Lua table so we can inspect the args
107
+ local payload = cjson.decode(item)
108
+
109
+ -- Resque stores job arguments as an array under the "args" key.
110
+ -- payload["args"] is a Lua table (1-indexed array).
111
+ local job_args = payload["args"]
112
+
113
+ -- Extract the partition value. First try hash-key lookup, then positional.
114
+ local partition_value = nil
115
+ for _, arg in ipairs(job_args) do
116
+ if type(arg) == "table" and arg[partition_key] ~= nil then
117
+ partition_value = arg[partition_key]
118
+ break
119
+ end
120
+ end
121
+
122
+ if partition_value == nil and partition_index > 0 then
123
+ partition_value = job_args[partition_index]
124
+ end
125
+
126
+ -- If this job doesn't have a partition value, skip it.
127
+ if partition_value ~= nil then
128
+
129
+ -- ================================================================
130
+ -- STEP 2a: Look up how many jobs this partition has running
131
+ -- ================================================================
132
+ -- Build the key: e.g. "resque:fair_share:default:42:in_flight"
133
+ local in_flight_key = prefix .. ":" .. tostring(partition_value) .. ":in_flight"
134
+
135
+ -- GET returns a string or nil. Convert to number, default to 0.
136
+ -- A partition with no key means it has nothing running yet.
137
+ local in_flight = tonumber(redis.call("GET", in_flight_key)) or 0
138
+
139
+ -- ================================================================
140
+ -- STEP 2b: Check if this partition is eligible
141
+ -- ================================================================
142
+ -- Skip partitions that have hit their max. They're "full" and
143
+ -- should not take on more work right now.
144
+ if max_in_flight == 0 or in_flight < max_in_flight then
145
+
146
+ -- ============================================================
147
+ -- STEP 2c: Is this better than our current best?
148
+ -- ============================================================
149
+ -- Lower in_flight = more underserved = should get priority.
150
+ -- On equal scores, the first one we saw wins (closer to queue front).
151
+ if best_score == nil or in_flight < best_score then
152
+ best_score = in_flight
153
+ best_item = item
154
+ end
155
+
156
+ -- Optimization: 0 in-flight is the minimum possible score.
157
+ -- We can't find anything better, so stop scanning early.
158
+ if best_score == 0 then
159
+ break
160
+ end
161
+ end
162
+ end
163
+ end
164
+
165
+ -- ============================================================================
166
+ -- STEP 3: Remove the winner from the queue and return it
167
+ -- ============================================================================
168
+ if best_item ~= nil then
169
+ -- LREM removes elements from a list by value.
170
+ -- Arguments: key, count, value
171
+ -- count=1 means "remove the first occurrence scanning from head to tail"
172
+ -- We use the exact JSON string from LRANGE, so it matches perfectly.
173
+ -- This is atomic with the rest of the script; no other worker can grab it.
174
+ redis.call("LREM", KEYS[1], 1, best_item)
175
+ return best_item
176
+ end
177
+
178
+ -- No eligible job found. Either:
179
+ -- - Every partition in the scan window is at max_in_flight
180
+ -- - Every job in the scan window is malformed (no partition key)
181
+ -- The worker will get nil and try again on the next poll cycle.
182
+ return nil
@@ -0,0 +1,50 @@
1
+ -- fair_dequeue_v2.lua
2
+ --
3
+ -- Per-partition sub-queue dequeue with O(log k) partition selection via ZSET.
4
+ -- The partitions ZSET is scored by in_flight count, so the member with the
5
+ -- lowest score is always the most underserved partition.
6
+ --
7
+ -- KEYS[1] = partitions ZSET (e.g. "resque:fair_share:default:partitions")
8
+ -- KEYS[2] = main queue LIST (e.g. "resque:queue:default")
9
+ --
10
+ -- ARGV[1] = sub-queue key prefix (e.g. "resque:fair_share:default:sub:")
11
+ -- ARGV[2] = max_in_flight (number as string, 0 = no limit)
12
+
13
+ local sub_prefix = ARGV[1]
14
+ local max_in_flight = tonumber(ARGV[2])
15
+
16
+ -- Step 1: Find eligible partitions starting from the lowest score.
17
+ -- ZRANGEBYSCORE returns members scored between min and max, ordered by score.
18
+ -- We ask for a small batch and check eligibility.
19
+ local max_score = "inf"
20
+ if max_in_flight > 0 then
21
+ max_score = tostring(max_in_flight - 1)
22
+ end
23
+
24
+ local candidates = redis.call("ZRANGEBYSCORE", KEYS[1], 0, max_score, "LIMIT", 0, 1)
25
+
26
+ if #candidates == 0 then
27
+ return nil
28
+ end
29
+
30
+ local best_partition = candidates[1]
31
+
32
+ -- Step 2: LPOP from the best partition's sub-queue
33
+ local sub_key = sub_prefix .. best_partition
34
+ local item = redis.call("LPOP", sub_key)
35
+
36
+ if item == nil then
37
+ -- Stale ZSET entry (sub-queue already drained)
38
+ redis.call("ZREM", KEYS[1], best_partition)
39
+ return nil
40
+ end
41
+
42
+ -- Step 3: Remove from main queue for consistency
43
+ redis.call("LREM", KEYS[2], 1, item)
44
+
45
+ -- Step 4: Clean up ZSET if sub-queue is now empty
46
+ if redis.call("LLEN", sub_key) == 0 then
47
+ redis.call("ZREM", KEYS[1], best_partition)
48
+ end
49
+
50
+ return item
@@ -0,0 +1,17 @@
1
+ -- fair_enqueue.lua
2
+ --
3
+ -- Atomically routes a job into its partition sub-queue and registers the
4
+ -- partition in the priority ZSET (scored by in_flight count).
5
+ --
6
+ -- KEYS[1] = sub-queue key (e.g. "resque:fair_share:default:sub:42")
7
+ -- KEYS[2] = partitions ZSET (e.g. "resque:fair_share:default:partitions")
8
+ --
9
+ -- ARGV[1] = job JSON payload
10
+ -- ARGV[2] = partition value (string)
11
+
12
+ redis.call("RPUSH", KEYS[1], ARGV[1])
13
+
14
+ -- NX: only add if not already a member (preserves current score for active partitions)
15
+ redis.call("ZADD", KEYS[2], "NX", 0, ARGV[2])
16
+
17
+ return 1
metadata ADDED
@@ -0,0 +1,92 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: resque-fair-share
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Duarte Reis
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-07-21 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: redis
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '4.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '4.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: resque
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '2.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '2.0'
41
+ description: A Resque plugin that implements weighted fair-share scheduling at the
42
+ worker level. Instead of FIFO processing, workers rotate through accounts proportionally
43
+ so that high-volume accounts don't starve smaller ones.
44
+ email:
45
+ - duarte.reis@zendesk.com
46
+ executables: []
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - CHANGELOG.md
51
+ - LICENSE
52
+ - README.md
53
+ - lib/resque-fair-share.rb
54
+ - lib/resque/fair_share.rb
55
+ - lib/resque/fair_share/configuration.rb
56
+ - lib/resque/fair_share/dequeue.rb
57
+ - lib/resque/fair_share/hooks.rb
58
+ - lib/resque/fair_share/migration.rb
59
+ - lib/resque/fair_share/reserve.rb
60
+ - lib/resque/fair_share/version.rb
61
+ - lib/resque/plugins/fair_share.rb
62
+ - lua/fair_dequeue.lua
63
+ - lua/fair_dequeue_v2.lua
64
+ - lua/fair_enqueue.lua
65
+ homepage: https://github.com/duarteareiasdosreis/resque-fair-share
66
+ licenses:
67
+ - MIT
68
+ metadata:
69
+ homepage_uri: https://github.com/duarteareiasdosreis/resque-fair-share
70
+ source_code_uri: https://github.com/duarteareiasdosreis/resque-fair-share
71
+ changelog_uri: https://github.com/duarteareiasdosreis/resque-fair-share/blob/main/CHANGELOG.md
72
+ rubygems_mfa_required: 'true'
73
+ post_install_message:
74
+ rdoc_options: []
75
+ require_paths:
76
+ - lib
77
+ required_ruby_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: 3.1.0
82
+ required_rubygems_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '0'
87
+ requirements: []
88
+ rubygems_version: 3.5.22
89
+ signing_key:
90
+ specification_version: 4
91
+ summary: Account-level queue fairness for Resque
92
+ test_files: []