sidekiq-throttled 2.0.0 → 2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 85023f0d147ed5c41ac214b6f8c907ac4fd8c023271e0def9297659305449b16
4
- data.tar.gz: 2f6b2c9c97210185dc91539328b85e108ee07b36770da7eaf87e27d3cff4138c
3
+ metadata.gz: dcec267e317bfaeabe891a21b776cbddc083cba27a163c82a5e5e0839390fb8d
4
+ data.tar.gz: '09706d9f91fdc770f01a5fb7835d8c35706c2852dfe798ec3e60dcd76bdeb8e9'
5
5
  SHA512:
6
- metadata.gz: 9fe961d74fd88be00803879e6a35197be285a80d7d7c289385bf6268a1c58c9e82a81c32ef0fb89070f8eccab6dc8ee963de2b2109695879be0c4b7d75bdc770
7
- data.tar.gz: ee02663fa70432081fefbb756b5be9a72917ef3dcee1cf915a221d97c21f7ceb65b04ac0a54d5a3dba0709a484361e04423e3cf7a0750ef7127df5d0d1796f92
6
+ metadata.gz: 4c90a776bb731f2a140469a03092e8307997602d7723f73517ed804f0f3973cbfed9692baf8f0f98e47320e0b5c4316bbaa15ffc36c70637d68d553e9a537d25
7
+ data.tar.gz: e52c211802a6e740dcd8b1b77ef3d7cad8b39d6a5aa714b7f1619c9ec2391d893140a636562cb6bafdc326ce4bcbd42904bfe053e6410ceec215720d72100b4a
data/README.adoc CHANGED
@@ -79,6 +79,30 @@ class MyWorker
79
79
  end
80
80
  ----
81
81
 
82
+ === Requeue Strategy
83
+ The default requeue strategy `:enqueue` puts jobs immiediately back on the queue if they are being limited, with a potential `cooldown_period`. This is often an appropriate strategy but in some situations may cause the system to repeatedly dequeue and reenque the same job causing excessive redis CPU usage.
84
+
85
+ An alternative requeue strategy `:schedule` is available that schedules the work for the future. This can be less appropriate for highly concurrent jobs, but may be a good way to reduce redis load when the number of jobs to be processed is modest.
86
+
87
+ The alternative `:schedule` strategy may be configured globally with `config.default_requeue_options = { with: :schedule }`
88
+
89
+ It may be configured on a per job basis with:
90
+
91
+ ```ruby
92
+ sidekiq_throttle(
93
+ # Allow 5 jobs to processed within 1 minute, using the
94
+ threshold: { limit: 5, period: 1.minute, requeue: {with: :schedule} }
95
+ )
96
+ ```
97
+
98
+ It is also possible to requeue jobs to another queue:
99
+
100
+ ```ruby
101
+ sidekiq_throttle(
102
+ # Allow 5 jobs to processed within 1 minute, using the
103
+ threshold: { limit: 5, period: 1.minute, requeue: {to: :other_queue, with: :schedule} }
104
+ )
105
+ ```
82
106
 
83
107
  === Web UI
84
108
 
@@ -246,8 +270,8 @@ class MyJob
246
270
  sidekiq_throttle(
247
271
  # Allow maximum 10 concurrent jobs per project at a time and maximum 2 jobs per user
248
272
  concurrency: [
249
- { limit: 10, key_suffix: -> (project_id, user_id) { project_id } },
250
- { limit: 2, key_suffix: -> (project_id, user_id) { user_id } }
273
+ { limit: 10, key_suffix: -> (project_id, user_id) { "project_id:#{project_id}" } },
274
+ { limit: 2, key_suffix: -> (project_id, user_id) { "user_id:#{user_id}" } }
251
275
  ]
252
276
  # For :threshold it works the same
253
277
  )
@@ -309,6 +333,47 @@ lock TTL to fit your needs:
309
333
  sidekiq_throttle(concurrency: { limit: 20, ttl: 1.hour.to_i })
310
334
  ----
311
335
 
336
+ === Scheduling based concurrency tuning
337
+
338
+ The default concurrency throttling algorithm immediately requeues throttled
339
+ jobs. This can lead to a lot of wasted work picking up the same set of still
340
+ throttled jobs repeatedly. This churn also often starves lower priority
341
+ jobs/queues. The `:schedule` requeue strategy delays checking the runability of
342
+ throttled jobs until likely to be runnable. This future time is estimated based
343
+ on the expected runtime of the job and current number of throttled jobs. This
344
+ eliminates -- or greatly reduces -- the negative impacts to non-throttled job
345
+ types and queues and reduces wasted work constantly rechecking the same still
346
+ throttled jobs.
347
+
348
+ **Config items**
349
+
350
+ - `limit` — Maximum number of this job allowed to run simultaneously.
351
+ - `avg_job_duration` — Expected runtime (in seconds) for this job type.
352
+ Choose a value on the high end of what’s plausible; if you set this too low under heavy load, job scheduling will become sub-optimal.
353
+ - `lost_job_threshold` — Duration (in seconds) representing how long a job “owns” its concurrency slot before being considered lost.
354
+ - `ttl` — Deprecated alias for `lost_job_threshold`.
355
+ - `max_delay` — The maximum number of seconds to delay a job when it is throttled.
356
+ Prevents excessively long scheduling delays as the backlog grows.
357
+ _Default: the smaller of 30 minutes or `10 × avg_job_duration`._
358
+
359
+ [source,ruby]
360
+ ----
361
+ sidekiq_throttle(
362
+ concurrency: {
363
+ # only run 10 of this job at a time
364
+ limit: 10,
365
+ # these jobs finish in less than 30 seconds
366
+ avg_job_duration: 30,
367
+ # if it doesn't release its lease in 2 minutes it's considered lost
368
+ lost_job_threshold: 120,
369
+ # maximum delay allowed when throttled
370
+ max_delay: 300
371
+ },
372
+ # requeue using Sidekiq's scheduler
373
+ requeue: { with: :schedule }
374
+ )
375
+ ----
376
+
312
377
 
313
378
  == Supported Ruby Versions
314
379
 
@@ -317,6 +382,7 @@ This library aims to support and is tested against the following Ruby versions:
317
382
  * Ruby 3.2.x
318
383
  * Ruby 3.3.x
319
384
  * Ruby 3.4.x
385
+ * Ruby 4.0.x
320
386
 
321
387
  If something doesn't work on one of these versions, it's a bug.
322
388
 
@@ -337,10 +403,12 @@ dropped.
337
403
  This library aims to support and work with following Sidekiq versions:
338
404
 
339
405
  * Sidekiq 8.0.x
406
+ * Sidekiq 8.1.x
340
407
 
341
- And the following Sidekiq Pro versions:
408
+ And (might work with) the following Sidekiq Pro versions:
342
409
 
343
410
  * Sidekiq Pro 8.0.x
411
+ * Sidekiq Pro 8.1.x
344
412
 
345
413
  == Development
346
414
 
@@ -14,7 +14,11 @@ module Sidekiq
14
14
  key = @base_key.dup
15
15
  return key unless @key_suffix
16
16
 
17
- key << ":#{@key_suffix.call(*job_args)}"
17
+ key << ":#{key_suffix(job_args)}"
18
+ end
19
+
20
+ def key_suffix(job_args)
21
+ @key_suffix.respond_to?(:call) ? @key_suffix.call(*job_args) : @key_suffix
18
22
  rescue StandardError => e
19
23
  Sidekiq.logger.error "Failed to get key suffix: #{e}"
20
24
  raise e
@@ -1,16 +1,61 @@
1
- local key = KEYS[1]
1
+ local in_progress_jobs_key = KEYS[1]
2
+ local backlog_info_key = KEYS[2]
2
3
  local jid = ARGV[1]
3
4
  local lmt = tonumber(ARGV[2])
4
- local ttl = tonumber(ARGV[3])
5
+ local lost_job_threshold = tonumber(ARGV[3])
5
6
  local now = tonumber(ARGV[4])
6
7
 
7
- redis.call("ZREMRANGEBYSCORE", key, "-inf", "(" .. now)
8
+ -- supporting functions
9
+ local function over_limit()
10
+ return lmt <= redis.call("ZCARD", in_progress_jobs_key)
11
+ end
12
+
13
+ local function job_already_in_progress()
14
+ return redis.call("ZSCORE", in_progress_jobs_key, jid)
15
+ end
16
+
17
+ -- Estimates current backlog size. This function tends to underestimate
18
+ -- the actual backlog. This is intentional. Overestimates are bad as it
19
+ -- can cause unnecessary delays in job processing. Underestimates are much
20
+ -- safer as they only increase workload of sidekiq processors.
21
+ local function est_current_backlog_size()
22
+ local old_size = tonumber(redis.call("HGET", backlog_info_key, "size")) or 0
23
+ local old_timestamp = tonumber(redis.call("HGET", backlog_info_key, "timestamp")) or now
24
+
25
+ local jobs_lost_since_old_timestamp = (now - old_timestamp) / lost_job_threshold * lmt
26
+
27
+ return math.max(old_size - jobs_lost_since_old_timestamp, 0)
28
+ end
29
+
30
+
31
+ local function change_backlog_size(delta)
32
+ local curr_backlog_size = est_current_backlog_size()
33
+
34
+ redis.call("HSET", backlog_info_key, "size", curr_backlog_size + delta)
35
+ redis.call("HSET", backlog_info_key, "timestamp", now)
36
+ redis.call("EXPIRE", backlog_info_key, math.ceil((lost_job_threshold * curr_backlog_size) + 1 / lmt))
37
+ end
38
+
39
+ local function register_job_in_progress()
40
+ redis.call("ZADD", in_progress_jobs_key, now + lost_job_threshold , jid)
41
+ redis.call("EXPIRE", in_progress_jobs_key, lost_job_threshold)
42
+ end
43
+
44
+ local function clear_stale_in_progress_jobs()
45
+ local cleared_count = redis.call("ZREMRANGEBYSCORE", in_progress_jobs_key, "-inf", "(" .. now)
46
+ change_backlog_size(-cleared_count)
47
+ end
48
+
49
+ -- END supporting functions
50
+
51
+ clear_stale_in_progress_jobs()
8
52
 
9
- if lmt <= redis.call("ZCARD", key) and not redis.call("ZSCORE", key, jid) then
53
+ if over_limit() and not job_already_in_progress() then
54
+ change_backlog_size(1)
10
55
  return 1
11
56
  end
12
57
 
13
- redis.call("ZADD", key, now + ttl, jid)
14
- redis.call("EXPIRE", key, ttl)
58
+ register_job_in_progress()
59
+ change_backlog_size(-1)
15
60
 
16
61
  return 0
@@ -26,13 +26,26 @@ module Sidekiq
26
26
  # @param [#to_s] strategy_key
27
27
  # @param [#to_i, #call] limit Amount of allowed concurrent jobs
28
28
  # per processors running for given key.
29
- # @param [#to_i] ttl Concurrency lock TTL in seconds.
29
+ # @param [#to_i] avg_job_duration Average number of seconds needed
30
+ # to complete a job of this type. Default: 300 or 1/3 of lost_job_threshold
31
+ # @param [#to_i] lost_job_threshold Seconds to wait before considering
32
+ # a job lost or dead. Default: 900 or 3 * avg_job_duration
30
33
  # @param [Proc] key_suffix Dynamic key suffix generator.
31
- def initialize(strategy_key, limit:, ttl: 900, key_suffix: nil)
32
- @base_key = "#{strategy_key}:concurrency.v2"
33
- @limit = limit
34
- @ttl = ttl.to_i
34
+ # @param [#to_i] max_delay Maximum number of seconds to delay a job when it
35
+ # throttled. This prevents jobs from being schedule very far in the future
36
+ # when the backlog is large. Default: the smaller of 30 minutes or 10 * avg_job_duration
37
+ # @deprecated @param [#to_i] ttl Obsolete alias for `lost_job_threshold`.
38
+ # Default: 900 or 3 * avg_job_duration
39
+ def initialize(strategy_key, limit:, avg_job_duration: nil, ttl: nil, # rubocop:disable Metrics/ParameterLists
40
+ lost_job_threshold: ttl, key_suffix: nil, max_delay: nil)
41
+ @base_key = "#{strategy_key}:concurrency.v2"
42
+ @limit = limit
43
+ @avg_job_duration, @lost_job_threshold = interp_duration_args(avg_job_duration, lost_job_threshold)
35
44
  @key_suffix = key_suffix
45
+ @max_delay = max_delay || [(10 * @avg_job_duration), 1_800].min
46
+
47
+ raise(ArgumentError, "lost_job_threshold must be greater than avg_job_duration") if
48
+ @lost_job_threshold <= @avg_job_duration
36
49
  end
37
50
 
38
51
  # @return [Boolean] Whenever strategy has dynamic config
@@ -46,8 +59,8 @@ module Sidekiq
46
59
  return false unless job_limit
47
60
  return true if job_limit <= 0
48
61
 
49
- keys = [key(job_args)]
50
- argv = [jid.to_s, job_limit, @ttl, Time.now.to_f]
62
+ keys = [key(job_args), backlog_info_key(job_args)]
63
+ argv = [jid.to_s, job_limit, @lost_job_threshold, Time.now.to_f]
51
64
 
52
65
  Sidekiq.redis { |redis| 1 == SCRIPT.call(redis, keys: keys, argv: argv) }
53
66
  end
@@ -57,11 +70,8 @@ module Sidekiq
57
70
  job_limit = limit(job_args)
58
71
  return 0.0 if !job_limit || count(*job_args) < job_limit
59
72
 
60
- oldest_jid_with_score = Sidekiq.redis { |redis| redis.zrange(key(job_args), 0, 0, withscores: true) }.first
61
- return 0.0 unless oldest_jid_with_score
62
-
63
- expiry_time = oldest_jid_with_score.last.to_f
64
- expiry_time - Time.now.to_f
73
+ (estimated_backlog_size(job_args) * @avg_job_duration / limit(job_args))
74
+ .then { |delay_sec| @max_delay * (1 - Math.exp(-delay_sec / @max_delay)) } # limit to max_delay
65
75
  end
66
76
 
67
77
  # @return [Integer] Current count of jobs
@@ -78,7 +88,40 @@ module Sidekiq
78
88
  # Remove jid from the pool of jobs in progress
79
89
  # @return [void]
80
90
  def finalize!(jid, *job_args)
81
- Sidekiq.redis { |conn| conn.zrem(key(job_args), jid.to_s) }
91
+ Sidekiq.redis do |conn|
92
+ conn.zrem(key(job_args), jid.to_s)
93
+ end
94
+ end
95
+
96
+ private
97
+
98
+ def backlog_info_key(job_args)
99
+ "#{key(job_args)}.backlog_info"
100
+ end
101
+
102
+ def estimated_backlog_size(job_args)
103
+ old_size_str, old_timestamp_str =
104
+ Sidekiq.redis { |conn| conn.hmget(backlog_info_key(job_args), "size", "timestamp") }
105
+ old_size = (old_size_str || 0).to_f
106
+ old_timestamp = (old_timestamp_str || Time.now).to_f
107
+
108
+ (old_size - jobs_lost_since(old_timestamp, job_args)).clamp(0, Float::INFINITY)
109
+ end
110
+
111
+ def jobs_lost_since(timestamp, job_args)
112
+ (Time.now.to_f - timestamp) / @lost_job_threshold * limit(job_args)
113
+ end
114
+
115
+ def interp_duration_args(avg_job_duration, lost_job_threshold)
116
+ if avg_job_duration && lost_job_threshold
117
+ [avg_job_duration.to_i, lost_job_threshold.to_i]
118
+ elsif avg_job_duration && lost_job_threshold.nil?
119
+ [avg_job_duration.to_i, avg_job_duration.to_i * 3]
120
+ elsif avg_job_duration.nil? && lost_job_threshold
121
+ [lost_job_threshold.to_i / 3, lost_job_threshold.to_i]
122
+ else
123
+ [300, 900]
124
+ end
82
125
  end
83
126
  end
84
127
  end
@@ -3,6 +3,6 @@
3
3
  module Sidekiq
4
4
  module Throttled
5
5
  # Gem version
6
- VERSION = "2.0.0"
6
+ VERSION = "2.1.0"
7
7
  end
8
8
  end
@@ -62,12 +62,13 @@ module Sidekiq
62
62
 
63
63
  # @return [String]
64
64
  def humanize_integer(int)
65
- digits = int.to_s.chars
66
- str = digits.shift(digits.count % 3).join
67
-
68
- str << " " << digits.shift(3).join while digits.count.positive?
69
-
70
- str.strip
65
+ int.to_s.chars
66
+ .reverse
67
+ .each_slice(3)
68
+ .map(&:reverse)
69
+ .reverse
70
+ .map(&:join)
71
+ .join(",")
71
72
  end
72
73
  end
73
74
  end
@@ -0,0 +1 @@
1
+ index.erb
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sidekiq-throttled
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alexey Zapparov
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2025-06-09 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: concurrent-ruby
@@ -52,7 +51,6 @@ dependencies:
52
51
  - - ">="
53
52
  - !ruby/object:Gem::Version
54
53
  version: '8.0'
55
- description:
56
54
  email:
57
55
  - alexey@zapparov.com
58
56
  executables: []
@@ -85,16 +83,16 @@ files:
85
83
  - lib/sidekiq/throttled/web/stats.rb
86
84
  - lib/sidekiq/throttled/worker.rb
87
85
  - web/views/index.erb
86
+ - web/views/index.html.erb
88
87
  homepage: https://github.com/ixti/sidekiq-throttled
89
88
  licenses:
90
89
  - MIT
91
90
  metadata:
92
91
  homepage_uri: https://github.com/ixti/sidekiq-throttled
93
- source_code_uri: https://github.com/ixti/sidekiq-throttled/tree/v2.0.0
92
+ source_code_uri: https://github.com/ixti/sidekiq-throttled/tree/v2.1.0
94
93
  bug_tracker_uri: https://github.com/ixti/sidekiq-throttled/issues
95
- changelog_uri: https://github.com/ixti/sidekiq-throttled/blob/v2.0.0/CHANGES.md
94
+ changelog_uri: https://github.com/ixti/sidekiq-throttled/blob/v2.1.0/CHANGES.md
96
95
  rubygems_mfa_required: 'true'
97
- post_install_message:
98
96
  rdoc_options: []
99
97
  require_paths:
100
98
  - lib
@@ -109,8 +107,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
109
107
  - !ruby/object:Gem::Version
110
108
  version: '0'
111
109
  requirements: []
112
- rubygems_version: 3.4.19
113
- signing_key:
110
+ rubygems_version: 3.6.9
114
111
  specification_version: 4
115
112
  summary: Concurrency and rate-limit throttling for Sidekiq
116
113
  test_files: []