sidekiq-throttled 1.0.0.alpha → 1.0.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: 7659ca0e722a87bc85f4e9d73a38075cf005b72515bbaf06604eabaab77d8fdd
4
- data.tar.gz: 28c53a85bb5d346d09d8ec08c6d23cecc687b6ec35e4c9779c07eba32bc635d6
3
+ metadata.gz: c7e45d38e16a4fd149ab2a5df541308d54304ae35bbd337d3701e534bffa025b
4
+ data.tar.gz: 7aa8a7a9469dbec0d06df4829dee111f015119861ac0caff00ca70148d014602
5
5
  SHA512:
6
- metadata.gz: 2b03a9d8f51981c778c4a7b0fb84d7c20dabf902eefb00518a98a4c5a028119e820cd93920946516a51e0a399e05cf2692d12e7b59b814b37045b4dc6f852c43
7
- data.tar.gz: 37478b95ae4f5426f82dceec1aa918722772c9a2c785b7c94420460b897c52cc0eee1cce02f5d03214c5d28113c3c798f5c13e31fb342ae935d92a07bc60f9b7
6
+ metadata.gz: 29e1a58bf302daeacb18695a618e55eec6ec177112274ac5dc85f7fbc1b14cb56c1bb8f8e9250dd22683182c8baee0d95c40f3708dba8518a5dde7cfbc71bbb6
7
+ data.tar.gz: 24ada7f5c9ee65400d1442feb72f95428ef9efdaf7aac2d38b732b2f4d649709712fb25d6b97e2c59627647bdcadf4c5d206fbb46f9b8321a5d293184866f2d2
data/README.adoc CHANGED
@@ -89,6 +89,25 @@ end
89
89
  ----
90
90
 
91
91
 
92
+ === Configuration
93
+
94
+ [source,ruby]
95
+ ----
96
+ Sidekiq::Throttled.configure do |config|
97
+ # Period in seconds to exclude queue from polling in case it returned
98
+ # {config.cooldown_threshold} amount of throttled jobs in a row. Set
99
+ # this value to `nil` to disable cooldown manager completely.
100
+ # Default: 2.0
101
+ config.cooldown_period = 2.0
102
+
103
+ # Exclude queue from polling after it returned given amount of throttled
104
+ # jobs in a row.
105
+ # Default: 1 (cooldown after first throttled job)
106
+ config.cooldown_threshold = 1
107
+ end
108
+ ----
109
+
110
+
92
111
  === Observer
93
112
 
94
113
  You can specify an observer that will be called on throttling. To do so pass an
@@ -232,7 +251,6 @@ sidekiq_throttle(concurrency: { limit: 20, ttl: 1.hour.to_i })
232
251
 
233
252
  This library aims to support and is tested against the following Ruby versions:
234
253
 
235
- * Ruby 2.7.x
236
254
  * Ruby 3.0.x
237
255
  * Ruby 3.1.x
238
256
  * Ruby 3.2.x
@@ -255,9 +273,9 @@ dropped.
255
273
 
256
274
  This library aims to support and work with following Sidekiq versions:
257
275
 
258
- * Sidekiq 6.5.x
259
276
  * Sidekiq 7.0.x
260
277
  * Sidekiq 7.1.x
278
+ * Sidekiq 7.2.x
261
279
 
262
280
 
263
281
  == Development
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sidekiq
4
+ module Throttled
5
+ # Configuration object.
6
+ class Config
7
+ # Period in seconds to exclude queue from polling in case it returned
8
+ # {#cooldown_threshold} amount of throttled jobs in a row.
9
+ #
10
+ # Set this to `nil` to disable cooldown completely.
11
+ #
12
+ # @return [Float, nil]
13
+ attr_reader :cooldown_period
14
+
15
+ # Amount of throttled jobs returned from the queue subsequently after
16
+ # which queue will be excluded from polling for the durations of
17
+ # {#cooldown_period}.
18
+ #
19
+ # @return [Integer]
20
+ attr_reader :cooldown_threshold
21
+
22
+ def initialize
23
+ @cooldown_period = 2.0
24
+ @cooldown_threshold = 1
25
+ end
26
+
27
+ # @!attribute [w] cooldown_period
28
+ def cooldown_period=(value)
29
+ raise TypeError, "unexpected type #{value.class}" unless value.nil? || value.is_a?(Float)
30
+ raise ArgumentError, "period must be positive" unless value.nil? || value.positive?
31
+
32
+ @cooldown_period = value
33
+ end
34
+
35
+ # @!attribute [w] cooldown_threshold
36
+ def cooldown_threshold=(value)
37
+ raise TypeError, "unexpected type #{value.class}" unless value.is_a?(Integer)
38
+ raise ArgumentError, "threshold must be positive" unless value.positive?
39
+
40
+ @cooldown_threshold = value
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+
5
+ require_relative "./expirable_set"
6
+
7
+ module Sidekiq
8
+ module Throttled
9
+ # @api internal
10
+ #
11
+ # Queues cooldown manager. Tracks list of queues that should be temporarily
12
+ # (for the duration of {Config#cooldown_period}) excluded from polling.
13
+ class Cooldown
14
+ class << self
15
+ # Returns new {Cooldown} instance if {Config#cooldown_period} is not `nil`.
16
+ #
17
+ # @param config [Config]
18
+ # @return [Cooldown, nil]
19
+ def [](config)
20
+ new(config) if config.cooldown_period
21
+ end
22
+ end
23
+
24
+ # @param config [Config]
25
+ def initialize(config)
26
+ @queues = ExpirableSet.new(config.cooldown_period)
27
+ @threshold = config.cooldown_threshold
28
+ @tracker = Concurrent::Map.new
29
+ end
30
+
31
+ # Notify that given queue returned job that was throttled.
32
+ #
33
+ # @param queue [String]
34
+ # @return [void]
35
+ def notify_throttled(queue)
36
+ @queues.add(queue) if @threshold <= @tracker.merge_pair(queue, 1, &:succ)
37
+ end
38
+
39
+ # Notify that given queue returned job that was not throttled.
40
+ #
41
+ # @param queue [String]
42
+ # @return [void]
43
+ def notify_admitted(queue)
44
+ @tracker.delete(queue)
45
+ end
46
+
47
+ # List of queues that should not be polled
48
+ #
49
+ # @return [Array<String>]
50
+ def queues
51
+ @queues.to_a
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+
5
+ module Sidekiq
6
+ module Throttled
7
+ # @api internal
8
+ #
9
+ # Set of elements with expirations.
10
+ #
11
+ # @example
12
+ # set = ExpirableSet.new(10.0)
13
+ # set.add("a")
14
+ # sleep(5)
15
+ # set.add("b")
16
+ # set.to_a # => ["a", "b"]
17
+ # sleep(5)
18
+ # set.to_a # => ["b"]
19
+ class ExpirableSet
20
+ include Enumerable
21
+
22
+ # @param ttl [Float] expiration is seconds
23
+ # @raise [ArgumentError] if `ttl` is not positive Float
24
+ def initialize(ttl)
25
+ raise ArgumentError, "ttl must be positive Float" unless ttl.is_a?(Float) && ttl.positive?
26
+
27
+ @elements = Concurrent::Map.new
28
+ @ttl = ttl
29
+ end
30
+
31
+ # @param element [Object]
32
+ # @return [ExpirableSet] self
33
+ def add(element)
34
+ # cleanup expired elements to avoid mem-leak
35
+ horizon = now
36
+ expired = @elements.each_pair.select { |(_, sunset)| expired?(sunset, horizon) }
37
+ expired.each { |pair| @elements.delete_pair(*pair) }
38
+
39
+ # add new element
40
+ @elements[element] = now + @ttl
41
+
42
+ self
43
+ end
44
+
45
+ # @yield [Object] Gives each live (not expired) element to the block
46
+ def each
47
+ return to_enum __method__ unless block_given?
48
+
49
+ horizon = now
50
+
51
+ @elements.each_pair do |element, sunset|
52
+ yield element unless expired?(sunset, horizon)
53
+ end
54
+
55
+ self
56
+ end
57
+
58
+ private
59
+
60
+ # @return [Float]
61
+ def now
62
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
63
+ end
64
+
65
+ def expired?(sunset, horizon)
66
+ sunset <= horizon
67
+ end
68
+ end
69
+ end
70
+ end
@@ -13,7 +13,7 @@ module Sidekiq
13
13
  # include Sidekiq::Job
14
14
  # include Sidekiq::Throttled::Job
15
15
  #
16
- # sidkiq_options :queue => :my_queue
16
+ # sidekiq_options :queue => :my_queue
17
17
  # sidekiq_throttle :threshold => { :limit => 123, :period => 1.hour }
18
18
  #
19
19
  # def perform
@@ -29,8 +29,8 @@ module Sidekiq
29
29
  # in order to make API inline with `include Sidekiq::Job`.
30
30
  #
31
31
  # @private
32
- def self.included(worker)
33
- worker.send(:extend, ClassMethods)
32
+ def self.included(base)
33
+ base.extend(ClassMethods)
34
34
  end
35
35
 
36
36
  # Helper methods added to the singleton class of destination
@@ -9,14 +9,19 @@ module Sidekiq
9
9
  #
10
10
  # @private
11
11
  class Middleware
12
- include Sidekiq::ServerMiddleware if Sidekiq::VERSION >= "6.5.0"
12
+ include Sidekiq::ServerMiddleware
13
13
 
14
14
  # Called within Sidekiq job processing
15
15
  def call(_worker, msg, _queue)
16
16
  yield
17
17
  ensure
18
- Registry.get msg["class"] do |strategy|
19
- strategy.finalize!(msg["jid"], *msg["args"])
18
+ job = msg.fetch("wrapped") { msg["class"] }
19
+ jid = msg["jid"]
20
+
21
+ if job && jid
22
+ Registry.get job do |strategy|
23
+ strategy.finalize!(jid, *msg["args"])
24
+ end
20
25
  end
21
26
  end
22
27
  end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sidekiq"
4
+ require "sidekiq/fetch"
5
+
6
+ module Sidekiq
7
+ module Throttled
8
+ module Patches
9
+ module BasicFetch
10
+ # Retrieves job from redis.
11
+ #
12
+ # @return [Sidekiq::Throttled::UnitOfWork, nil]
13
+ def retrieve_work
14
+ work = super
15
+
16
+ if work && Throttled.throttled?(work.job)
17
+ Throttled.cooldown&.notify_throttled(work.queue)
18
+ requeue_throttled(work)
19
+ return nil
20
+ end
21
+
22
+ Throttled.cooldown&.notify_admitted(work.queue) if work
23
+
24
+ work
25
+ end
26
+
27
+ private
28
+
29
+ # Pushes job back to the head of the queue, so that job won't be tried
30
+ # immediately after it was requeued (in most cases).
31
+ #
32
+ # @note This is triggered when job is throttled. So it is same operation
33
+ # Sidekiq performs upon `Sidekiq::Worker.perform_async` call.
34
+ #
35
+ # @return [void]
36
+ def requeue_throttled(work)
37
+ redis { |conn| conn.lpush(work.queue, work.job) }
38
+ end
39
+
40
+ # Returns list of queues to try to fetch jobs from.
41
+ #
42
+ # @note It may return an empty array.
43
+ # @param [Array<String>] queues
44
+ # @return [Array<String>]
45
+ def queues_cmd
46
+ super - (Throttled.cooldown&.queues || [])
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+
53
+ Sidekiq::BasicFetch.prepend(Sidekiq::Throttled::Patches::BasicFetch)
@@ -102,8 +102,6 @@ module Sidekiq
102
102
  # @param name [Class, #to_s]
103
103
  # @return [Strategy, nil]
104
104
  def find_by_class(name)
105
- return unless Throttled.configuration.inherit_strategies?
106
-
107
105
  const = name.is_a?(Class) ? name : Object.const_get(name)
108
106
  return unless const.is_a?(Class)
109
107
 
@@ -112,6 +110,8 @@ module Sidekiq
112
110
  return strategy if strategy
113
111
  end
114
112
 
113
+ nil
114
+ rescue NameError
115
115
  nil
116
116
  end
117
117
  end
@@ -19,8 +19,7 @@ module Sidekiq
19
19
  # @param [#to_s] name
20
20
  # @param [#call] key_suffix Dynamic key suffix generator.
21
21
  def initialize(strategies, strategy:, name:, key_suffix:)
22
- strategies = (strategies.is_a?(Hash) ? [strategies] : Array(strategies))
23
- @strategies = strategies.map do |options|
22
+ @strategies = (strategies.is_a?(Hash) ? [strategies] : Array(strategies)).map do |options|
24
23
  make_strategy(strategy, name, key_suffix, options)
25
24
  end
26
25
  end
@@ -3,6 +3,6 @@
3
3
  module Sidekiq
4
4
  module Throttled
5
5
  # Gem version
6
- VERSION = "1.0.0.alpha"
6
+ VERSION = "1.0.0"
7
7
  end
8
8
  end
@@ -1,15 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # 3rd party
4
3
  require "sidekiq"
5
4
 
6
- # internal
7
- require_relative "./throttled/version"
8
- require_relative "./throttled/configuration"
9
- require_relative "./throttled/fetch"
10
- require_relative "./throttled/registry"
5
+ require_relative "./throttled/config"
6
+ require_relative "./throttled/cooldown"
11
7
  require_relative "./throttled/job"
12
8
  require_relative "./throttled/middleware"
9
+ require_relative "./throttled/patches/basic_fetch"
10
+ require_relative "./throttled/registry"
11
+ require_relative "./throttled/version"
13
12
  require_relative "./throttled/worker"
14
13
 
15
14
  # @see https://github.com/mperham/sidekiq/
@@ -42,21 +41,39 @@ module Sidekiq
42
41
  # end
43
42
  # end
44
43
  module Throttled
44
+ MUTEX = Mutex.new
45
+ private_constant :MUTEX
46
+
47
+ @config = Config.new.freeze
48
+ @cooldown = Cooldown[@config]
49
+
45
50
  class << self
46
- # @return [Configuration]
47
- def configuration
48
- @configuration ||= Configuration.new
49
- end
51
+ # @api internal
52
+ #
53
+ # @return [Cooldown, nil]
54
+ attr_reader :cooldown
50
55
 
51
- # Hooks throttler into sidekiq.
56
+ # @example
57
+ # Sidekiq::Throttled.configure do |config|
58
+ # config.cooldown_period = nil # Disable queues cooldown manager
59
+ # end
52
60
  #
53
- # @return [void]
61
+ # @yieldparam config [Config]
62
+ def configure
63
+ MUTEX.synchronize do
64
+ config = @config.dup
65
+
66
+ yield config
67
+
68
+ @config = config.freeze
69
+ @cooldown = Cooldown[@config]
70
+ end
71
+ end
72
+
54
73
  def setup!
55
74
  Sidekiq.configure_server do |config|
56
- if Gem::Version.new("7.0.0") <= Gem::Version.new(Sidekiq::VERSION)
57
- config[:fetch_class] = Sidekiq::Throttled::Fetch
58
- else
59
- config[:fetch] = Sidekiq::Throttled::Fetch.new(config)
75
+ config.server_middleware do |chain|
76
+ chain.add Sidekiq::Throttled::Middleware
60
77
  end
61
78
  end
62
79
  end
@@ -66,11 +83,13 @@ module Sidekiq
66
83
  # @param [String] message Job's JSON payload
67
84
  # @return [Boolean]
68
85
  def throttled?(message)
69
- message = JSON.parse message
70
- job = message.fetch("wrapped") { message.fetch("class") { return false } }
71
- jid = message.fetch("jid") { return false }
86
+ message = Sidekiq.load_json(message)
87
+ job = message.fetch("wrapped") { message["class"] }
88
+ jid = message["jid"]
72
89
 
73
- Registry.get job do |strategy|
90
+ return false unless job && jid
91
+
92
+ Registry.get(job) do |strategy|
74
93
  return strategy.throttled?(jid, *message["args"])
75
94
  end
76
95
 
@@ -80,10 +99,4 @@ module Sidekiq
80
99
  end
81
100
  end
82
101
  end
83
-
84
- configure_server do |config|
85
- config.server_middleware do |chain|
86
- chain.add Sidekiq::Throttled::Middleware
87
- end
88
- end
89
102
  end
metadata CHANGED
@@ -1,15 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sidekiq-throttled
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0.alpha
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alexey Zapparov
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-05-30 00:00:00.000000000 Z
11
+ date: 2023-11-20 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: concurrent-ruby
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 1.2.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 1.2.0
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: redis-prescription
15
29
  requirement: !ruby/object:Gem::Requirement
@@ -30,14 +44,14 @@ dependencies:
30
44
  requirements:
31
45
  - - ">="
32
46
  - !ruby/object:Gem::Version
33
- version: '6.5'
47
+ version: '7.0'
34
48
  type: :runtime
35
49
  prerelease: false
36
50
  version_requirements: !ruby/object:Gem::Requirement
37
51
  requirements:
38
52
  - - ">="
39
53
  - !ruby/object:Gem::Version
40
- version: '6.5'
54
+ version: '7.0'
41
55
  description:
42
56
  email:
43
57
  - alexey@zapparov.com
@@ -48,12 +62,13 @@ files:
48
62
  - LICENSE
49
63
  - README.adoc
50
64
  - lib/sidekiq/throttled.rb
51
- - lib/sidekiq/throttled/basic_fetch.rb
52
- - lib/sidekiq/throttled/configuration.rb
65
+ - lib/sidekiq/throttled/config.rb
66
+ - lib/sidekiq/throttled/cooldown.rb
53
67
  - lib/sidekiq/throttled/errors.rb
54
- - lib/sidekiq/throttled/fetch.rb
68
+ - lib/sidekiq/throttled/expirable_set.rb
55
69
  - lib/sidekiq/throttled/job.rb
56
70
  - lib/sidekiq/throttled/middleware.rb
71
+ - lib/sidekiq/throttled/patches/basic_fetch.rb
57
72
  - lib/sidekiq/throttled/registry.rb
58
73
  - lib/sidekiq/throttled/strategy.rb
59
74
  - lib/sidekiq/throttled/strategy/base.rb
@@ -72,9 +87,9 @@ licenses:
72
87
  - MIT
73
88
  metadata:
74
89
  homepage_uri: https://github.com/ixti/sidekiq-throttled
75
- source_code_uri: https://github.com/ixti/sidekiq-throttled/tree/v1.0.0.alpha
90
+ source_code_uri: https://github.com/ixti/sidekiq-throttled/tree/v1.0.0
76
91
  bug_tracker_uri: https://github.com/ixti/sidekiq-throttled/issues
77
- changelog_uri: https://github.com/ixti/sidekiq-throttled/blob/v1.0.0.alpha/CHANGES.md
92
+ changelog_uri: https://github.com/ixti/sidekiq-throttled/blob/v1.0.0/CHANGES.md
78
93
  rubygems_mfa_required: 'true'
79
94
  post_install_message:
80
95
  rdoc_options: []
@@ -84,12 +99,12 @@ required_ruby_version: !ruby/object:Gem::Requirement
84
99
  requirements:
85
100
  - - ">="
86
101
  - !ruby/object:Gem::Version
87
- version: '2.7'
102
+ version: '3.0'
88
103
  required_rubygems_version: !ruby/object:Gem::Requirement
89
104
  requirements:
90
- - - ">"
105
+ - - ">="
91
106
  - !ruby/object:Gem::Version
92
- version: 1.3.1
107
+ version: '0'
93
108
  requirements: []
94
109
  rubygems_version: 3.4.10
95
110
  signing_key:
@@ -1,55 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "sidekiq"
4
- require "sidekiq/fetch"
5
-
6
- module Sidekiq
7
- module Throttled
8
- # Default Sidekiq's BasicFetch infused with Throttler.
9
- #
10
- # @private
11
- class BasicFetch < Sidekiq::BasicFetch
12
- # Retrieves job from redis.
13
- #
14
- # @return [Sidekiq::Throttled::UnitOfWork, nil]
15
- def retrieve_work
16
- work = super
17
-
18
- if work && Throttled.throttled?(work.job)
19
- requeue_throttled(work)
20
- return nil
21
- end
22
-
23
- work
24
- end
25
-
26
- private
27
-
28
- # Pushes job back to the head of the queue, so that job won't be tried
29
- # immediately after it was requeued (in most cases).
30
- #
31
- # @note This is triggered when job is throttled. So it is same operation
32
- # Sidekiq performs upon `Sidekiq::Worker.perform_async` call.
33
- #
34
- # @return [void]
35
- def requeue_throttled(work)
36
- redis { |conn| conn.lpush(work.queue, work.job) }
37
- end
38
-
39
- # Returns list of queues to try to fetch jobs from.
40
- #
41
- # @note It may return an empty array.
42
- # @param [Array<String>] queues
43
- # @return [Array<String>]
44
- def queues_cmd
45
- queues = super
46
-
47
- # TODO: Refactor to be prepended as an integration mixin during configuration stage
48
- # Or via configurable queues reducer
49
- queues -= Sidekiq::Pauzer.paused_queues.map { |name| "queue:#{name}" } if defined?(Sidekiq::Pauzer)
50
-
51
- queues
52
- end
53
- end
54
- end
55
- end
@@ -1,50 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Sidekiq
4
- module Throttled
5
- # Configuration holder.
6
- class Configuration
7
- # Class constructor.
8
- def initialize
9
- reset!
10
- end
11
-
12
- # Reset configuration to defaults.
13
- #
14
- # @return [self]
15
- def reset!
16
- @inherit_strategies = false
17
-
18
- self
19
- end
20
-
21
- # Instructs throttler to lookup strategies in parent classes, if there's
22
- # no own strategy:
23
- #
24
- # class FooJob
25
- # include Sidekiq::Job
26
- # include Sidekiq::Throttled::Job
27
- #
28
- # sidekiq_throttle :concurrency => { :limit => 42 }
29
- # end
30
- #
31
- # class BarJob < FooJob
32
- # end
33
- #
34
- # By default in the example above, `Bar` won't have throttling options.
35
- # Set this flag to `true` to enable this lookup in initializer, after
36
- # that `Bar` will use `Foo` throttling bucket.
37
- def inherit_strategies=(value)
38
- @inherit_strategies = value ? true : false
39
- end
40
-
41
- # Whenever throttled workers should inherit parent's strategies or not.
42
- # Default: `false`.
43
- #
44
- # @return [Boolean]
45
- def inherit_strategies?
46
- @inherit_strategies
47
- end
48
- end
49
- end
50
- end
@@ -1,10 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "./basic_fetch"
4
-
5
- module Sidekiq
6
- module Throttled
7
- # @deprecated Use Sidekiq::Throttled::BasicFetch
8
- Fetch = BasicFetch
9
- end
10
- end