HornsAndHooves-sidekiq-limit_fetch 4.5.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.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +4 -0
  3. data/.rspec +5 -0
  4. data/.rubocop.yml +34 -0
  5. data/.ruby-gemset +1 -0
  6. data/.ruby-version +1 -0
  7. data/CHANGELOG.md +37 -0
  8. data/Gemfile +4 -0
  9. data/LICENSE +22 -0
  10. data/README.md +165 -0
  11. data/Rakefile +14 -0
  12. data/bench/compare.rb +56 -0
  13. data/demo/Gemfile +8 -0
  14. data/demo/README.md +37 -0
  15. data/demo/Rakefile +100 -0
  16. data/demo/app/workers/a_worker.rb +10 -0
  17. data/demo/app/workers/b_worker.rb +10 -0
  18. data/demo/app/workers/c_worker.rb +10 -0
  19. data/demo/app/workers/fast_worker.rb +10 -0
  20. data/demo/app/workers/slow_worker.rb +10 -0
  21. data/demo/config/application.rb +13 -0
  22. data/demo/config/boot.rb +4 -0
  23. data/demo/config/environment.rb +4 -0
  24. data/demo/config/environments/development.rb +11 -0
  25. data/lib/sidekiq/extensions/manager.rb +21 -0
  26. data/lib/sidekiq/extensions/queue.rb +27 -0
  27. data/lib/sidekiq/limit_fetch/global/monitor.rb +83 -0
  28. data/lib/sidekiq/limit_fetch/global/selector.rb +130 -0
  29. data/lib/sidekiq/limit_fetch/global/semaphore.rb +190 -0
  30. data/lib/sidekiq/limit_fetch/instances.rb +29 -0
  31. data/lib/sidekiq/limit_fetch/queues.rb +197 -0
  32. data/lib/sidekiq/limit_fetch/unit_of_work.rb +28 -0
  33. data/lib/sidekiq/limit_fetch.rb +76 -0
  34. data/lib/sidekiq-limit_fetch.rb +3 -0
  35. data/sidekiq-limit_fetch.gemspec +30 -0
  36. data/spec/sidekiq/extensions/manager_spec.rb +13 -0
  37. data/spec/sidekiq/extensions/queue_spec.rb +96 -0
  38. data/spec/sidekiq/limit_fetch/global/monitor_spec.rb +114 -0
  39. data/spec/sidekiq/limit_fetch/queues_spec.rb +127 -0
  40. data/spec/sidekiq/limit_fetch/semaphore_spec.rb +65 -0
  41. data/spec/sidekiq/limit_fetch_spec.rb +58 -0
  42. data/spec/spec_helper.rb +34 -0
  43. metadata +179 -0
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sidekiq
4
+ module LimitFetch
5
+ module Global
6
+ module Monitor
7
+ extend self
8
+
9
+ HEARTBEAT_PREFIX = 'limit:heartbeat:'
10
+ PROCESS_SET = 'limit:processes'
11
+ HEARTBEAT_TTL = 20
12
+ REFRESH_TIMEOUT = 5
13
+
14
+ def start!(ttl = HEARTBEAT_TTL, timeout = REFRESH_TIMEOUT)
15
+ Thread.new do
16
+ loop do
17
+ Sidekiq::LimitFetch.redis_retryable do
18
+ handle_dynamic_queues
19
+ update_heartbeat ttl
20
+ invalidate_old_processes
21
+ end
22
+
23
+ sleep timeout
24
+ end
25
+ end
26
+ end
27
+
28
+ def all_processes
29
+ Sidekiq.redis { |it| it.smembers PROCESS_SET }
30
+ end
31
+
32
+ def old_processes
33
+ all_processes.reject do |process|
34
+ Sidekiq.redis { |it| it.get heartbeat_key process } == '1'
35
+ end
36
+ end
37
+
38
+ def remove_old_processes!
39
+ Sidekiq.redis do |it|
40
+ old_processes.each { |process| it.srem PROCESS_SET, [process] }
41
+ end
42
+ end
43
+
44
+ def handle_dynamic_queues
45
+ queues = Sidekiq::LimitFetch::Queues
46
+ return unless queues.dynamic?
47
+
48
+ available_queues = Sidekiq::Queue.all.map(&:name).reject do |it|
49
+ queues.dynamic_exclude.include? it
50
+ end
51
+ queues.handle available_queues
52
+ end
53
+
54
+ private
55
+
56
+ def update_heartbeat(ttl)
57
+ Sidekiq.redis do |it|
58
+ it.multi do |pipeline|
59
+ pipeline.set heartbeat_key, '1'
60
+ pipeline.sadd PROCESS_SET, [Selector.uuid]
61
+ pipeline.expire heartbeat_key, ttl
62
+ end
63
+ end
64
+ end
65
+
66
+ def invalidate_old_processes
67
+ Sidekiq.redis do |_it|
68
+ remove_old_processes!
69
+ processes = all_processes
70
+
71
+ Sidekiq::Queue.instances.each do |queue|
72
+ queue.remove_locks_except! processes
73
+ end
74
+ end
75
+ end
76
+
77
+ def heartbeat_key(process = Selector.uuid)
78
+ HEARTBEAT_PREFIX + process
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sidekiq
4
+ module LimitFetch
5
+ module Global
6
+ module Selector
7
+ extend self
8
+
9
+ MUTEX_FOR_UUID = Mutex.new
10
+
11
+ def acquire(queues, namespace)
12
+ redis_eval :acquire, [namespace, uuid, queues]
13
+ end
14
+
15
+ def release(queues, namespace)
16
+ redis_eval :release, [namespace, uuid, queues]
17
+ end
18
+
19
+ def uuid
20
+ # - if we'll remove "@uuid ||=" from inside of mutex
21
+ # then @uuid can be overwritten
22
+ # - if we'll remove "@uuid ||=" from outside of mutex
23
+ # then each read will lead to mutex
24
+ @uuid ||= MUTEX_FOR_UUID.synchronize { @uuid || SecureRandom.uuid }
25
+ end
26
+
27
+ private
28
+
29
+ def redis_eval(script_name, args)
30
+ Sidekiq.redis do |it|
31
+ it.evalsha send("redis_#{script_name}_sha"), [], args
32
+ rescue Sidekiq::LimitFetch::RedisCommandError => e
33
+ raise unless e.message.include? 'NOSCRIPT'
34
+
35
+ it.eval send("redis_#{script_name}_script"), 0, *args
36
+ end
37
+ end
38
+
39
+ def redis_acquire_sha
40
+ @redis_acquire_sha ||= OpenSSL::Digest::SHA1.hexdigest redis_acquire_script
41
+ end
42
+
43
+ def redis_release_sha
44
+ @redis_release_sha ||= OpenSSL::Digest::SHA1.hexdigest redis_release_script
45
+ end
46
+
47
+ def redis_acquire_script
48
+ <<-LUA
49
+ local namespace = table.remove(ARGV, 1)..'limit_fetch:'
50
+ local worker_name = table.remove(ARGV, 1)
51
+ local queues = ARGV
52
+ local available = {}
53
+ local unblocked = {}
54
+ local locks
55
+ local process_locks
56
+ local blocking_mode
57
+
58
+ for _, queue in ipairs(queues) do
59
+ if not blocking_mode or unblocked[queue] then
60
+ local probed_key = namespace..'probed:'..queue
61
+ local pause_key = namespace..'pause:'..queue
62
+ local limit_key = namespace..'limit:'..queue
63
+ local process_limit_key = namespace..'process_limit:'..queue
64
+ local block_key = namespace..'block:'..queue
65
+
66
+ local paused, limit, process_limit, can_block =
67
+ unpack(redis.call('mget',
68
+ pause_key,
69
+ limit_key,
70
+ process_limit_key,
71
+ block_key
72
+ ))
73
+
74
+ if not paused then
75
+ limit = tonumber(limit)
76
+ process_limit = tonumber(process_limit)
77
+
78
+ if can_block or limit then
79
+ locks = redis.call('llen', probed_key)
80
+ end
81
+
82
+ if process_limit then
83
+ local all_locks = redis.call('lrange', probed_key, 0, -1)
84
+ process_locks = 0
85
+ for _, process in ipairs(all_locks) do
86
+ if process == worker_name then
87
+ process_locks = process_locks + 1
88
+ end
89
+ end
90
+ end
91
+
92
+ if not blocking_mode then
93
+ blocking_mode = can_block and locks > 0
94
+ end
95
+
96
+ if blocking_mode and can_block ~= 'true' then
97
+ for unblocked_queue in string.gmatch(can_block, "[^,]+") do
98
+ unblocked[unblocked_queue] = true
99
+ end
100
+ end
101
+
102
+ if (not limit or limit > locks) and
103
+ (not process_limit or process_limit > process_locks) then
104
+ redis.call('rpush', probed_key, worker_name)
105
+ table.insert(available, queue)
106
+ end
107
+ end
108
+ end
109
+ end
110
+
111
+ return available
112
+ LUA
113
+ end
114
+
115
+ def redis_release_script
116
+ <<-LUA
117
+ local namespace = table.remove(ARGV, 1)..'limit_fetch:'
118
+ local worker_name = table.remove(ARGV, 1)
119
+ local queues = ARGV
120
+
121
+ for _, queue in ipairs(queues) do
122
+ local probed_key = namespace..'probed:'..queue
123
+ redis.call('lrem', probed_key, 1, worker_name)
124
+ end
125
+ LUA
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sidekiq
4
+ module LimitFetch
5
+ module Global
6
+ class Semaphore
7
+ PREFIX = 'limit_fetch'
8
+
9
+ attr_reader :local_busy
10
+
11
+ def initialize(name)
12
+ @name = name
13
+ @lock = Mutex.new
14
+ @local_busy = 0
15
+ end
16
+
17
+ def limit
18
+ value = redis { |it| it.get "#{PREFIX}:limit:#{@name}" }
19
+ value&.to_i
20
+ end
21
+
22
+ def limit=(value)
23
+ @limit_changed = true
24
+
25
+ if value
26
+ redis { |it| it.set "#{PREFIX}:limit:#{@name}", value }
27
+ else
28
+ redis { |it| it.del "#{PREFIX}:limit:#{@name}" }
29
+ end
30
+ end
31
+
32
+ def limit_changed?
33
+ @limit_changed
34
+ end
35
+
36
+ def process_limit
37
+ value = redis { |it| it.get "#{PREFIX}:process_limit:#{@name}" }
38
+ value&.to_i
39
+ end
40
+
41
+ def process_limit=(value)
42
+ if value
43
+ redis { |it| it.set "#{PREFIX}:process_limit:#{@name}", value }
44
+ else
45
+ redis { |it| it.del "#{PREFIX}:process_limit:#{@name}" }
46
+ end
47
+ end
48
+
49
+ def acquire
50
+ Selector.acquire([@name], namespace).size.positive?
51
+ end
52
+
53
+ def release
54
+ redis { |it| it.lrem "#{PREFIX}:probed:#{@name}", 1, Selector.uuid }
55
+ end
56
+
57
+ def busy
58
+ redis { |it| it.llen "#{PREFIX}:busy:#{@name}" }
59
+ end
60
+
61
+ def busy_processes
62
+ redis { |it| it.lrange "#{PREFIX}:busy:#{@name}", 0, -1 }
63
+ end
64
+
65
+ def increase_busy
66
+ increase_local_busy
67
+ redis { |it| it.rpush "#{PREFIX}:busy:#{@name}", Selector.uuid }
68
+ end
69
+
70
+ def decrease_busy
71
+ decrease_local_busy
72
+ redis { |it| it.lrem "#{PREFIX}:busy:#{@name}", 1, Selector.uuid }
73
+ end
74
+
75
+ def probed
76
+ redis { |it| it.llen "#{PREFIX}:probed:#{@name}" }
77
+ end
78
+
79
+ def probed_processes
80
+ redis { |it| it.lrange "#{PREFIX}:probed:#{@name}", 0, -1 }
81
+ end
82
+
83
+ def pause
84
+ redis { |it| it.set "#{PREFIX}:pause:#{@name}", '1' }
85
+ end
86
+
87
+ def pause_for_ms(milliseconds)
88
+ redis { |it| it.psetex "#{PREFIX}:pause:#{@name}", milliseconds, 1 }
89
+ end
90
+
91
+ def unpause
92
+ redis { |it| it.del "#{PREFIX}:pause:#{@name}" }
93
+ end
94
+
95
+ def paused?
96
+ redis { |it| it.get "#{PREFIX}:pause:#{@name}" } == '1'
97
+ end
98
+
99
+ def block
100
+ redis { |it| it.set "#{PREFIX}:block:#{@name}", '1' }
101
+ end
102
+
103
+ def block_except(*queues)
104
+ raise ArgumentError if queues.empty?
105
+
106
+ redis { |it| it.set "#{PREFIX}:block:#{@name}", queues.join(',') }
107
+ end
108
+
109
+ def unblock
110
+ redis { |it| it.del "#{PREFIX}:block:#{@name}" }
111
+ end
112
+
113
+ def blocking?
114
+ redis { |it| it.get "#{PREFIX}:block:#{@name}" } == '1'
115
+ end
116
+
117
+ def clear_limits
118
+ redis do |it|
119
+ %w[block busy limit pause probed process_limit].each do |key|
120
+ it.del "#{PREFIX}:#{key}:#{@name}"
121
+ end
122
+ end
123
+ end
124
+
125
+ def increase_local_busy
126
+ @lock.synchronize { @local_busy += 1 }
127
+ end
128
+
129
+ def decrease_local_busy
130
+ @lock.synchronize { @local_busy -= 1 }
131
+ end
132
+
133
+ def local_busy?
134
+ @local_busy.positive?
135
+ end
136
+
137
+ def explain
138
+ <<-INFO.gsub(/^ {8}/, '')
139
+ Current sidekiq process: #{Selector.uuid}
140
+
141
+ All processes:
142
+ #{Monitor.all_processes.join "\n"}
143
+
144
+ Stale processes:
145
+ #{Monitor.old_processes.join "\n"}
146
+
147
+ Locked queue processes:
148
+ #{probed_processes.sort.join "\n"}
149
+
150
+ Busy queue processes:
151
+ #{busy_processes.sort.join "\n"}
152
+
153
+ Limit:
154
+ #{limit.inspect}
155
+
156
+ Process limit:
157
+ #{process_limit.inspect}
158
+
159
+ Blocking:
160
+ #{blocking?}
161
+ INFO
162
+ end
163
+
164
+ def remove_locks_except!(processes)
165
+ locked_processes = probed_processes.uniq
166
+ (locked_processes - processes).each do |dead_process|
167
+ remove_lock! dead_process
168
+ end
169
+ end
170
+
171
+ def remove_lock!(process)
172
+ redis do |it|
173
+ it.lrem "#{PREFIX}:probed:#{@name}", 0, process
174
+ it.lrem "#{PREFIX}:busy:#{@name}", 0, process
175
+ end
176
+ end
177
+
178
+ private
179
+
180
+ def redis(&block)
181
+ Sidekiq.redis(&block)
182
+ end
183
+
184
+ def namespace
185
+ Sidekiq::LimitFetch::Queues.namespace
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sidekiq
4
+ module LimitFetch
5
+ module Instances
6
+ def self.extended(klass)
7
+ klass.instance_variable_set :@instances, {}
8
+ end
9
+
10
+ def new(*args)
11
+ @instances[args] ||= super
12
+ end
13
+
14
+ alias [] new
15
+
16
+ def instances
17
+ @instances.values
18
+ end
19
+
20
+ def reset_instances!
21
+ @instances = {}
22
+ end
23
+
24
+ def delete_instance(name)
25
+ @instances.delete [name]
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sidekiq
4
+ module LimitFetch
5
+ module Queues
6
+ extend self
7
+
8
+ THREAD_KEY = :acquired_queues
9
+
10
+ # rubocop:disable Metrics/AbcSize
11
+ # rubocop:disable Metrics/CyclomaticComplexity
12
+ # rubocop:disable Metrics/MethodLength
13
+ # rubocop:disable Metrics/PerceivedComplexity
14
+ def start(capsule)
15
+ config = capsule.config
16
+ @queues = capsule.queues.map { |queue| queue.is_a?(Array) ? queue.first : queue }.uniq
17
+
18
+ @startup_queues = @queues.dup
19
+
20
+ if config[:dynamic].is_a? Hash
21
+ @dynamic = true
22
+ @dynamic_exclude = config[:dynamic][:exclude] || []
23
+ else
24
+ @dynamic = config[:dynamic]
25
+ @dynamic_exclude = []
26
+ end
27
+
28
+ @limits = config[:limits] || {}
29
+ @process_limits = config[:process_limits] || {}
30
+ @blocks = config[:blocking] || []
31
+
32
+ config[:strict] ? strict_order! : weighted_order!
33
+
34
+ apply_process_limit_to_queues
35
+ apply_limit_to_queues
36
+ apply_blocks_to_queues
37
+ end
38
+ # rubocop:enable Metrics/AbcSize
39
+ # rubocop:enable Metrics/CyclomaticComplexity
40
+ # rubocop:enable Metrics/MethodLength
41
+ # rubocop:enable Metrics/PerceivedComplexity
42
+
43
+ def acquire
44
+ queues = saved
45
+ queues ||= Sidekiq::LimitFetch.redis_retryable do
46
+ selector.acquire(ordered_queues, namespace)
47
+ end
48
+ save queues
49
+ queues.map { |it| "queue:#{it}" }
50
+ end
51
+
52
+ def release_except(full_name)
53
+ queues = restore
54
+ queues.delete full_name[/queue:(.*)/, 1] if full_name
55
+ Sidekiq::LimitFetch.redis_retryable do
56
+ selector.release queues, namespace
57
+ end
58
+ end
59
+
60
+ def dynamic?
61
+ @dynamic
62
+ end
63
+
64
+ def startup_queue?(queue)
65
+ @startup_queues.include?(queue)
66
+ end
67
+
68
+ def dynamic_exclude
69
+ @dynamic_exclude
70
+ end
71
+
72
+ def add(queues)
73
+ return unless queues
74
+
75
+ queues.each do |queue|
76
+ next if @queues.include? queue
77
+
78
+ if startup_queue?(queue)
79
+ apply_process_limit_to_queue(queue)
80
+ apply_limit_to_queue(queue)
81
+ end
82
+
83
+ @queues.push queue
84
+ end
85
+ end
86
+
87
+ def remove(queues)
88
+ return unless queues
89
+
90
+ queues.each do |queue|
91
+ next unless @queues.include? queue
92
+
93
+ clear_limits_for_queue(queue)
94
+ @queues.delete queue
95
+ Sidekiq::Queue.delete_instance(queue)
96
+ end
97
+ end
98
+
99
+ def handle(queues)
100
+ add(queues - @queues)
101
+ remove(@queues - queues)
102
+ end
103
+
104
+ # rubocop:disable Lint/NestedMethodDefinition
105
+ def strict_order!
106
+ @queues.uniq!
107
+ def ordered_queues
108
+ @queues
109
+ end
110
+ end
111
+
112
+ def weighted_order!
113
+ def ordered_queues
114
+ @queues.shuffle.uniq
115
+ end
116
+ end
117
+ # rubocop:enable Lint/NestedMethodDefinition
118
+
119
+ def namespace
120
+ @namespace ||= Sidekiq.redis do |it|
121
+ if it.respond_to?(:namespace) && it.namespace
122
+ "#{it.namespace}:"
123
+ else
124
+ ''
125
+ end
126
+ end
127
+ end
128
+
129
+ private
130
+
131
+ def apply_process_limit_to_queues
132
+ @queues.uniq.each do |queue_name|
133
+ apply_process_limit_to_queue(queue_name)
134
+ end
135
+ end
136
+
137
+ def apply_process_limit_to_queue(queue_name)
138
+ queue = Sidekiq::Queue[queue_name]
139
+ return unless queue.process_limit.nil? # honor existing deployed limits
140
+
141
+ queue.process_limit = @process_limits[queue_name.to_s] || @process_limits[queue_name.to_sym]
142
+ end
143
+
144
+ def apply_limit_to_queues
145
+ @queues.uniq.each do |queue_name|
146
+ apply_limit_to_queue(queue_name)
147
+ end
148
+ end
149
+
150
+ def apply_limit_to_queue(queue_name)
151
+ queue = Sidekiq::Queue[queue_name]
152
+
153
+ return if queue.limit_changed?
154
+ return unless queue.limit.nil? # honor existing deployed limits
155
+
156
+ queue.limit = @limits[queue_name.to_s] || @limits[queue_name.to_sym]
157
+ end
158
+
159
+ def apply_blocks_to_queues
160
+ @queues.uniq.each do |queue_name|
161
+ Sidekiq::Queue[queue_name].unblock
162
+ end
163
+
164
+ @blocks.to_a.each do |it|
165
+ if it.is_a? Array
166
+ it.each { |name| Sidekiq::Queue[name].block_except it }
167
+ else
168
+ Sidekiq::Queue[it].block
169
+ end
170
+ end
171
+ end
172
+
173
+ def clear_limits_for_queue(queue_name)
174
+ queue = Sidekiq::Queue[queue_name]
175
+ queue.clear_limits
176
+ end
177
+
178
+ def selector
179
+ Sidekiq::LimitFetch::Global::Selector
180
+ end
181
+
182
+ def saved
183
+ Thread.current[THREAD_KEY]
184
+ end
185
+
186
+ def save(queues)
187
+ Thread.current[THREAD_KEY] = queues
188
+ end
189
+
190
+ def restore
191
+ saved || []
192
+ ensure
193
+ save nil
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sidekiq
4
+ module LimitFetch
5
+ class UnitOfWork < BasicFetch::UnitOfWork
6
+ def initialize(...)
7
+ super
8
+ redis_retryable { Queue[queue_name].increase_busy }
9
+ end
10
+
11
+ def acknowledge
12
+ redis_retryable { Queue[queue_name].decrease_busy }
13
+ redis_retryable { Queue[queue_name].release }
14
+ end
15
+
16
+ def requeue
17
+ super
18
+ acknowledge
19
+ end
20
+
21
+ private
22
+
23
+ def redis_retryable(&block)
24
+ Sidekiq::LimitFetch.redis_retryable(&block)
25
+ end
26
+ end
27
+ end
28
+ end