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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE +21 -0
- data/README.md +222 -0
- data/lib/resque/fair_share/configuration.rb +32 -0
- data/lib/resque/fair_share/dequeue.rb +54 -0
- data/lib/resque/fair_share/hooks.rb +8 -0
- data/lib/resque/fair_share/migration.rb +56 -0
- data/lib/resque/fair_share/reserve.rb +28 -0
- data/lib/resque/fair_share/version.rb +5 -0
- data/lib/resque/fair_share.rb +26 -0
- data/lib/resque/plugins/fair_share.rb +141 -0
- data/lib/resque-fair-share.rb +1 -0
- data/lua/fair_dequeue.lua +182 -0
- data/lua/fair_dequeue_v2.lua +50 -0
- data/lua/fair_enqueue.lua +17 -0
- metadata +92 -0
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
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,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,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: []
|