sidekiq-throttled 1.0.0.alpha → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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