sidekiq-throttled 1.0.0.alpha.1 → 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: 714915692e57a08eeea5dd0e0be79f3becfa915669f559f3457509073b89e4fe
4
- data.tar.gz: 190b4e6d013712725bb64277c00c74c82da5149f73368dbb19890090d638a372
3
+ metadata.gz: c7e45d38e16a4fd149ab2a5df541308d54304ae35bbd337d3701e534bffa025b
4
+ data.tar.gz: 7aa8a7a9469dbec0d06df4829dee111f015119861ac0caff00ca70148d014602
5
5
  SHA512:
6
- metadata.gz: 7627db53db315ec4acdaf15e715186522c3dbbdc4084aadd65cd0f081845b247f92eb106bcf2e5bf41b5098400adaed00377104b21205b5914495bcacff9d625
7
- data.tar.gz: 1caca54490b41115389b6ce111c4264a911aba84f656546bce0c62846d6f3828c91a1c681bfb0520f04230c6e113b9d1cd5553c500e6a65a7f6d77c2d36903a2
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
@@ -7,12 +7,6 @@ module Sidekiq
7
7
  module Throttled
8
8
  module Patches
9
9
  module BasicFetch
10
- class << self
11
- def apply!
12
- Sidekiq::BasicFetch.prepend(self) unless Sidekiq::BasicFetch.include?(self)
13
- end
14
- end
15
-
16
10
  # Retrieves job from redis.
17
11
  #
18
12
  # @return [Sidekiq::Throttled::UnitOfWork, nil]
@@ -20,10 +14,13 @@ module Sidekiq
20
14
  work = super
21
15
 
22
16
  if work && Throttled.throttled?(work.job)
17
+ Throttled.cooldown&.notify_throttled(work.queue)
23
18
  requeue_throttled(work)
24
19
  return nil
25
20
  end
26
21
 
22
+ Throttled.cooldown&.notify_admitted(work.queue) if work
23
+
27
24
  work
28
25
  end
29
26
 
@@ -46,15 +43,11 @@ module Sidekiq
46
43
  # @param [Array<String>] queues
47
44
  # @return [Array<String>]
48
45
  def queues_cmd
49
- queues = super
50
-
51
- # TODO: Refactor to be prepended as an integration mixin during configuration stage
52
- # Or via configurable queues reducer
53
- queues -= Sidekiq::Pauzer.paused_queues.map { |name| "queue:#{name}" } if defined?(Sidekiq::Pauzer)
54
-
55
- queues
46
+ super - (Throttled.cooldown&.queues || [])
56
47
  end
57
48
  end
58
49
  end
59
50
  end
60
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
@@ -3,6 +3,6 @@
3
3
  module Sidekiq
4
4
  module Throttled
5
5
  # Gem version
6
- VERSION = "1.0.0.alpha.1"
6
+ VERSION = "1.0.0"
7
7
  end
8
8
  end
@@ -2,12 +2,13 @@
2
2
 
3
3
  require "sidekiq"
4
4
 
5
- require_relative "./throttled/version"
6
- require_relative "./throttled/configuration"
7
- require_relative "./throttled/patches/basic_fetch"
8
- require_relative "./throttled/registry"
5
+ require_relative "./throttled/config"
6
+ require_relative "./throttled/cooldown"
9
7
  require_relative "./throttled/job"
10
8
  require_relative "./throttled/middleware"
9
+ require_relative "./throttled/patches/basic_fetch"
10
+ require_relative "./throttled/registry"
11
+ require_relative "./throttled/version"
11
12
  require_relative "./throttled/worker"
12
13
 
13
14
  # @see https://github.com/mperham/sidekiq/
@@ -40,17 +41,41 @@ module Sidekiq
40
41
  # end
41
42
  # end
42
43
  module Throttled
44
+ MUTEX = Mutex.new
45
+ private_constant :MUTEX
46
+
47
+ @config = Config.new.freeze
48
+ @cooldown = Cooldown[@config]
49
+
43
50
  class << self
44
- # @return [Configuration]
45
- def configuration
46
- @configuration ||= Configuration.new
47
- end
51
+ # @api internal
52
+ #
53
+ # @return [Cooldown, nil]
54
+ attr_reader :cooldown
48
55
 
49
- # Hooks throttler into sidekiq.
56
+ # @example
57
+ # Sidekiq::Throttled.configure do |config|
58
+ # config.cooldown_period = nil # Disable queues cooldown manager
59
+ # end
50
60
  #
51
- # @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
+
52
73
  def setup!
53
- Sidekiq::Throttled::Patches::BasicFetch.apply!
74
+ Sidekiq.configure_server do |config|
75
+ config.server_middleware do |chain|
76
+ chain.add Sidekiq::Throttled::Middleware
77
+ end
78
+ end
54
79
  end
55
80
 
56
81
  # Tells whenever job is throttled or not.
@@ -58,11 +83,13 @@ module Sidekiq
58
83
  # @param [String] message Job's JSON payload
59
84
  # @return [Boolean]
60
85
  def throttled?(message)
61
- message = JSON.parse message
62
- job = message.fetch("wrapped") { message.fetch("class") { return false } }
63
- jid = message.fetch("jid") { return false }
86
+ message = Sidekiq.load_json(message)
87
+ job = message.fetch("wrapped") { message["class"] }
88
+ jid = message["jid"]
89
+
90
+ return false unless job && jid
64
91
 
65
- Registry.get job do |strategy|
92
+ Registry.get(job) do |strategy|
66
93
  return strategy.throttled?(jid, *message["args"])
67
94
  end
68
95
 
@@ -72,10 +99,4 @@ module Sidekiq
72
99
  end
73
100
  end
74
101
  end
75
-
76
- configure_server do |config|
77
- config.server_middleware do |chain|
78
- chain.add Sidekiq::Throttled::Middleware
79
- end
80
- end
81
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.1
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-06-08 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,8 +62,10 @@ files:
48
62
  - LICENSE
49
63
  - README.adoc
50
64
  - lib/sidekiq/throttled.rb
51
- - lib/sidekiq/throttled/configuration.rb
65
+ - lib/sidekiq/throttled/config.rb
66
+ - lib/sidekiq/throttled/cooldown.rb
52
67
  - lib/sidekiq/throttled/errors.rb
68
+ - lib/sidekiq/throttled/expirable_set.rb
53
69
  - lib/sidekiq/throttled/job.rb
54
70
  - lib/sidekiq/throttled/middleware.rb
55
71
  - lib/sidekiq/throttled/patches/basic_fetch.rb
@@ -71,9 +87,9 @@ licenses:
71
87
  - MIT
72
88
  metadata:
73
89
  homepage_uri: https://github.com/ixti/sidekiq-throttled
74
- source_code_uri: https://github.com/ixti/sidekiq-throttled/tree/v1.0.0.alpha.1
90
+ source_code_uri: https://github.com/ixti/sidekiq-throttled/tree/v1.0.0
75
91
  bug_tracker_uri: https://github.com/ixti/sidekiq-throttled/issues
76
- changelog_uri: https://github.com/ixti/sidekiq-throttled/blob/v1.0.0.alpha.1/CHANGES.md
92
+ changelog_uri: https://github.com/ixti/sidekiq-throttled/blob/v1.0.0/CHANGES.md
77
93
  rubygems_mfa_required: 'true'
78
94
  post_install_message:
79
95
  rdoc_options: []
@@ -83,12 +99,12 @@ required_ruby_version: !ruby/object:Gem::Requirement
83
99
  requirements:
84
100
  - - ">="
85
101
  - !ruby/object:Gem::Version
86
- version: '2.7'
102
+ version: '3.0'
87
103
  required_rubygems_version: !ruby/object:Gem::Requirement
88
104
  requirements:
89
- - - ">"
105
+ - - ">="
90
106
  - !ruby/object:Gem::Version
91
- version: 1.3.1
107
+ version: '0'
92
108
  requirements: []
93
109
  rubygems_version: 3.4.10
94
110
  signing_key:
@@ -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