shoryuken 2.1.0 → 3.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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/.codeclimate.yml +5 -8
  3. data/.rubocop.yml +8 -2
  4. data/.travis.yml +5 -0
  5. data/CHANGELOG.md +88 -11
  6. data/README.md +20 -57
  7. data/Rakefile +0 -1
  8. data/bin/cli/base.rb +42 -0
  9. data/bin/cli/sqs.rb +188 -0
  10. data/bin/shoryuken +47 -9
  11. data/examples/default_worker.rb +1 -1
  12. data/lib/shoryuken/client.rb +3 -15
  13. data/lib/shoryuken/default_worker_registry.rb +9 -5
  14. data/lib/shoryuken/environment_loader.rb +11 -40
  15. data/lib/shoryuken/fetcher.rb +22 -53
  16. data/lib/shoryuken/launcher.rb +5 -29
  17. data/lib/shoryuken/manager.rb +72 -190
  18. data/lib/shoryuken/message.rb +4 -13
  19. data/lib/shoryuken/middleware/chain.rb +1 -18
  20. data/lib/shoryuken/middleware/server/auto_extend_visibility.rb +13 -14
  21. data/lib/shoryuken/middleware/server/exponential_backoff_retry.rb +29 -20
  22. data/lib/shoryuken/polling.rb +204 -0
  23. data/lib/shoryuken/processor.rb +6 -14
  24. data/lib/shoryuken/queue.rb +1 -3
  25. data/lib/shoryuken/runner.rb +143 -0
  26. data/lib/shoryuken/util.rb +0 -8
  27. data/lib/shoryuken/version.rb +1 -1
  28. data/lib/shoryuken/worker.rb +1 -1
  29. data/lib/shoryuken.rb +77 -55
  30. data/shoryuken.gemspec +6 -6
  31. data/spec/integration/launcher_spec.rb +4 -3
  32. data/spec/shoryuken/client_spec.rb +2 -45
  33. data/spec/shoryuken/default_worker_registry_spec.rb +12 -10
  34. data/spec/shoryuken/environment_loader_spec.rb +34 -0
  35. data/spec/shoryuken/fetcher_spec.rb +18 -52
  36. data/spec/shoryuken/manager_spec.rb +56 -97
  37. data/spec/shoryuken/middleware/chain_spec.rb +0 -24
  38. data/spec/shoryuken/middleware/server/auto_delete_spec.rb +2 -2
  39. data/spec/shoryuken/middleware/server/auto_extend_visibility_spec.rb +7 -3
  40. data/spec/shoryuken/middleware/server/exponential_backoff_retry_spec.rb +56 -33
  41. data/spec/shoryuken/polling_spec.rb +239 -0
  42. data/spec/shoryuken/processor_spec.rb +5 -5
  43. data/spec/shoryuken/{cli_spec.rb → runner_spec.rb} +8 -22
  44. data/spec/shoryuken_spec.rb +13 -1
  45. data/spec/spec_helper.rb +3 -18
  46. data/test_workers/endless_interruptive_worker.rb +41 -0
  47. data/test_workers/endless_uninterruptive_worker.rb +44 -0
  48. metadata +32 -34
  49. data/lib/shoryuken/aws_config.rb +0 -64
  50. data/lib/shoryuken/cli.rb +0 -215
  51. data/lib/shoryuken/sns_arn.rb +0 -27
  52. data/lib/shoryuken/topic.rb +0 -17
  53. data/spec/shoryuken/sns_arn_spec.rb +0 -42
  54. data/spec/shoryuken/topic_spec.rb +0 -32
  55. data/spec/shoryuken_endpoint.yml +0 -6
@@ -1,33 +1,32 @@
1
- require 'celluloid/current' unless defined?(Celluloid)
2
-
3
1
  module Shoryuken
4
2
  module Middleware
5
3
  module Server
6
4
  class AutoExtendVisibility
5
+ include Util
6
+
7
7
  EXTEND_UPFRONT_SECONDS = 5
8
8
 
9
9
  def call(worker, queue, sqs_msg, body)
10
- timer = auto_visibility_timer(worker, queue, sqs_msg, body)
11
- begin
12
- yield
13
- ensure
14
- if timer
15
- timer.cancel
16
- @visibility_extender.terminate
17
- end
10
+ if sqs_msg.is_a?(Array)
11
+ logger.warn { "Auto extend visibility isn't supported for batch workers" }
12
+ return yield
18
13
  end
14
+
15
+ timer = auto_visibility_timer(worker, queue, sqs_msg, body)
16
+ yield
17
+ ensure
18
+ timer.kill if timer
19
19
  end
20
20
 
21
21
  private
22
22
 
23
23
  class MessageVisibilityExtender
24
- include Celluloid
25
24
  include Util
26
25
 
27
26
  def auto_extend(worker, queue, sqs_msg, body)
28
27
  queue_visibility_timeout = Shoryuken::Client.queues(queue).visibility_timeout
29
28
 
30
- every(queue_visibility_timeout - EXTEND_UPFRONT_SECONDS) do
29
+ Concurrent::TimerTask.new(execution_interval: queue_visibility_timeout - EXTEND_UPFRONT_SECONDS) do
31
30
  begin
32
31
  logger.debug do
33
32
  "Extending message #{worker_name(worker.class, sqs_msg, body)}/#{queue}/#{sqs_msg.message_id} " \
@@ -48,8 +47,8 @@ module Shoryuken
48
47
 
49
48
  def auto_visibility_timer(worker, queue, sqs_msg, body)
50
49
  return unless worker.class.auto_visibility_timeout?
51
- @visibility_extender = MessageVisibilityExtender.new_link
52
- @visibility_extender.auto_extend(worker, queue, sqs_msg, body)
50
+
51
+ MessageVisibilityExtender.new.auto_extend(worker, queue, sqs_msg, body).tap(&:execute)
53
52
  end
54
53
  end
55
54
  end
@@ -5,12 +5,17 @@ module Shoryuken
5
5
  include Util
6
6
 
7
7
  def call(worker, queue, sqs_msg, body)
8
+ if sqs_msg.is_a?(Array)
9
+ logger.warn { "Exponential backoff isn't supported for batch workers" }
10
+ return yield
11
+ end
12
+
8
13
  started_at = Time.now
9
14
  yield
10
15
  rescue
11
- retry_intervals = Array(worker.class.get_shoryuken_options['retry_intervals'])
16
+ retry_intervals = worker.class.get_shoryuken_options['retry_intervals']
12
17
 
13
- if retry_intervals.empty? || !handle_failure(sqs_msg, started_at, retry_intervals)
18
+ if retry_intervals.nil? || !handle_failure(sqs_msg, started_at, retry_intervals)
14
19
  # Re-raise the exception if the job is not going to be exponential backoff retried.
15
20
  # This allows custom middleware (like exception notifiers) to be aware of the unhandled failure.
16
21
  raise
@@ -19,28 +24,32 @@ module Shoryuken
19
24
 
20
25
  private
21
26
 
22
- def handle_failure(sqs_msg, started_at, retry_intervals)
23
- return unless attempts = sqs_msg.attributes['ApproximateReceiveCount']
24
-
25
- attempts = attempts.to_i - 1
26
-
27
- interval = if attempts < retry_intervals.size
28
- retry_intervals[attempts]
29
- else
30
- retry_intervals.last
31
- end
32
-
33
- # Visibility timeouts are limited to a total 12 hours, starting from the receipt of the message.
34
- # We calculate the maximum timeout by subtracting the amount of time since the receipt of the message.
35
- #
36
- # From the docs: "Amazon SQS restarts the timeout period using the new value."
37
- # http://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/AboutVT.html#AboutVT-extending-message-visibility-timeout
38
- max_timeout = 43200 - (Time.now - started_at).ceil - 1
27
+ def get_interval(retry_intervals, attempts)
28
+ return retry_intervals.call(attempts) if retry_intervals.respond_to?(:call)
29
+
30
+ if attempts <= (retry_intervals = Array(retry_intervals)).size
31
+ retry_intervals[attempts - 1]
32
+ else
33
+ retry_intervals.last
34
+ end
35
+ end
36
+
37
+ def next_visibility_timeout(interval, started_at)
38
+ max_timeout = 43_200 - (Time.now - started_at).ceil - 1
39
39
  interval = max_timeout if interval > max_timeout
40
+ interval.to_i
41
+ end
42
+
43
+ def handle_failure(sqs_msg, started_at, retry_intervals)
44
+ receive_count = sqs_msg.attributes['ApproximateReceiveCount'].to_i
45
+
46
+ return false unless (interval = get_interval(retry_intervals, receive_count))
40
47
 
41
- sqs_msg.change_visibility(visibility_timeout: interval.to_i)
48
+ sqs_msg.change_visibility(visibility_timeout: next_visibility_timeout(interval.to_i, started_at))
42
49
 
43
50
  logger.info { "Message #{sqs_msg.message_id} failed, will be retried in #{interval} seconds." }
51
+
52
+ true
44
53
  end
45
54
  end
46
55
  end
@@ -0,0 +1,204 @@
1
+ module Shoryuken
2
+ module Polling
3
+ QueueConfiguration = Struct.new(:name, :options) do
4
+ def hash
5
+ name.hash
6
+ end
7
+
8
+ def ==(other)
9
+ case other
10
+ when String
11
+ if options.empty?
12
+ name == other
13
+ else
14
+ false
15
+ end
16
+ else
17
+ super
18
+ end
19
+ end
20
+
21
+ alias_method :eql?, :==
22
+
23
+ def to_s
24
+ if options.empty?
25
+ name
26
+ else
27
+ "#<QueueConfiguration #{name} options=#{options.inspect}>"
28
+ end
29
+ end
30
+ end
31
+
32
+ class BaseStrategy
33
+ include Util
34
+
35
+ def next_queue
36
+ fail NotImplementedError
37
+ end
38
+
39
+ def messages_found(queue, messages_found)
40
+ fail NotImplementedError
41
+ end
42
+
43
+ def active_queues
44
+ fail NotImplementedError
45
+ end
46
+
47
+ def ==(other)
48
+ case other
49
+ when Array
50
+ @queues == other
51
+ else
52
+ if other.respond_to?(:active_queues)
53
+ active_queues == other.active_queues
54
+ else
55
+ false
56
+ end
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def delay
63
+ Shoryuken.options[:delay].to_f
64
+ end
65
+ end
66
+
67
+ class WeightedRoundRobin < BaseStrategy
68
+ def initialize(queues)
69
+ @initial_queues = queues
70
+ @queues = queues.dup.uniq
71
+ @paused_queues = []
72
+ end
73
+
74
+ def next_queue
75
+ unpause_queues
76
+ queue = @queues.shift
77
+ return nil if queue.nil?
78
+
79
+ @queues << queue
80
+ QueueConfiguration.new(queue, {})
81
+ end
82
+
83
+ def messages_found(queue, messages_found)
84
+ if messages_found == 0
85
+ pause(queue)
86
+ return
87
+ end
88
+
89
+ maximum_weight = maximum_queue_weight(queue)
90
+ current_weight = current_queue_weight(queue)
91
+ if maximum_weight > current_weight
92
+ logger.info { "Increasing '#{queue}' weight to #{current_weight + 1}, max: #{maximum_weight}" }
93
+ @queues << queue
94
+ end
95
+ end
96
+
97
+ def active_queues
98
+ unparse_queues(@queues)
99
+ end
100
+
101
+ private
102
+
103
+ def pause(queue)
104
+ return unless @queues.delete(queue)
105
+ @paused_queues << [Time.now + delay, queue]
106
+ logger.debug "Paused '#{queue}'"
107
+ end
108
+
109
+ def unpause_queues
110
+ return if @paused_queues.empty?
111
+ return if Time.now < @paused_queues.first[0]
112
+ pause = @paused_queues.shift
113
+ @queues << pause[1]
114
+ logger.debug "Unpaused '#{pause[1]}'"
115
+ end
116
+
117
+ def current_queue_weight(queue)
118
+ queue_weight(@queues, queue)
119
+ end
120
+
121
+ def maximum_queue_weight(queue)
122
+ queue_weight(@initial_queues, queue)
123
+ end
124
+
125
+ def queue_weight(queues, queue)
126
+ queues.count { |q| q == queue }
127
+ end
128
+ end
129
+
130
+ class StrictPriority < BaseStrategy
131
+ def initialize(queues)
132
+ # Priority ordering of the queues, highest priority first
133
+ @queues = queues
134
+ .group_by { |q| q }
135
+ .sort_by { |_, qs| -qs.count }
136
+ .map(&:first)
137
+
138
+ # Pause status of the queues, default to past time (unpaused)
139
+ @paused_until = queues
140
+ .each_with_object(Hash.new) { |queue, h| h[queue] = Time.at(0) }
141
+
142
+ # Start queues at 0
143
+ reset_next_queue
144
+ end
145
+
146
+ def next_queue
147
+ next_queue = next_active_queue
148
+ next_queue.nil? ? nil : QueueConfiguration.new(next_queue, {})
149
+ end
150
+
151
+ def messages_found(queue, messages_found)
152
+ if messages_found == 0
153
+ pause(queue)
154
+ else
155
+ reset_next_queue
156
+ end
157
+ end
158
+
159
+ def active_queues
160
+ @queues
161
+ .reverse
162
+ .map.with_index(1)
163
+ .reject { |q, _| queue_paused?(q) }
164
+ .reverse
165
+ end
166
+
167
+ private
168
+
169
+ def next_active_queue
170
+ reset_next_queue if queues_unpaused_since?
171
+
172
+ size = @queues.length
173
+ size.times do
174
+ queue = @queues[@next_queue_index]
175
+ @next_queue_index = (@next_queue_index + 1) % size
176
+ return queue unless queue_paused?(queue)
177
+ end
178
+
179
+ nil
180
+ end
181
+
182
+ def queues_unpaused_since?
183
+ last = @last_unpause_check
184
+ now = @last_unpause_check = Time.now
185
+
186
+ last && @paused_until.values.any? { |t| t > last && t <= now }
187
+ end
188
+
189
+ def reset_next_queue
190
+ @next_queue_index = 0
191
+ end
192
+
193
+ def queue_paused?(queue)
194
+ @paused_until[queue] > Time.now
195
+ end
196
+
197
+ def pause(queue)
198
+ return unless delay > 0
199
+ @paused_until[queue] = Time.now + delay
200
+ logger.debug "Paused '#{queue}'"
201
+ end
202
+ end
203
+ end
204
+ end
@@ -1,28 +1,20 @@
1
- require 'json'
2
-
3
1
  module Shoryuken
4
2
  class Processor
5
- include Celluloid
6
3
  include Util
7
4
 
8
5
  def initialize(manager)
9
6
  @manager = manager
10
7
  end
11
8
 
12
- attr_accessor :proxy_id
13
-
14
9
  def process(queue, sqs_msg)
15
- @manager.async.real_thread(proxy_id, Thread.current)
16
-
17
10
  worker = Shoryuken.worker_registry.fetch_worker(queue, sqs_msg)
11
+ body = get_body(worker.class, sqs_msg)
18
12
 
19
- body = get_body(worker.class, sqs_msg)
20
-
21
- worker.class.server_middleware.invoke(worker, queue, sqs_msg, body) do
22
- worker.perform(sqs_msg, body)
23
- end
24
-
25
- @manager.async.processor_done(queue, current_actor)
13
+ worker.class.server_middleware.invoke(worker, queue, sqs_msg, body) do
14
+ worker.perform(sqs_msg, body)
15
+ end
16
+ ensure
17
+ @manager.processor_done(queue)
26
18
  end
27
19
 
28
20
  private
@@ -35,9 +35,7 @@ module Shoryuken
35
35
  end
36
36
 
37
37
  def receive_messages(options)
38
- client.receive_message(options.merge(queue_url: url)).
39
- messages.
40
- map { |m| Message.new(client, self, m) }
38
+ client.receive_message(options.merge(queue_url: url)).messages.map { |m| Message.new(client, self, m) }
41
39
  end
42
40
 
43
41
  def fifo?
@@ -0,0 +1,143 @@
1
+ $stdout.sync = true
2
+
3
+ require 'singleton'
4
+ require 'optparse'
5
+ require 'erb'
6
+
7
+ require 'shoryuken'
8
+
9
+ module Shoryuken
10
+ # rubocop:disable Lint/InheritException
11
+ # rubocop:disable Metrics/AbcSize
12
+ # See: https://github.com/mperham/sidekiq/blob/33f5d6b2b6c0dfaab11e5d39688cab7ebadc83ae/lib/sidekiq/cli.rb#L20
13
+ class Shutdown < Interrupt; end
14
+
15
+ class Runner
16
+ include Util
17
+ include Singleton
18
+
19
+ def run(options)
20
+ self_read, self_write = IO.pipe
21
+
22
+ %w(INT TERM USR1 USR2 TTIN).each do |sig|
23
+ begin
24
+ trap sig do
25
+ self_write.puts(sig)
26
+ end
27
+ rescue ArgumentError
28
+ puts "Signal #{sig} not supported"
29
+ end
30
+ end
31
+
32
+ loader = EnvironmentLoader.setup_options(options)
33
+
34
+ # When cli args exist, override options in config file
35
+ Shoryuken.options.merge!(options)
36
+
37
+ daemonize(Shoryuken.options)
38
+ write_pid(Shoryuken.options)
39
+
40
+ loader.load
41
+
42
+ initialize_concurrent_logger
43
+
44
+ @launcher = Shoryuken::Launcher.new
45
+
46
+ if (callback = Shoryuken.start_callback)
47
+ logger.info { 'Calling Shoryuken.on_start block' }
48
+ callback.call
49
+ end
50
+
51
+ fire_event(:startup)
52
+
53
+ begin
54
+ @launcher.run
55
+
56
+ while (readable_io = IO.select([self_read]))
57
+ signal = readable_io.first[0].gets.strip
58
+ handle_signal(signal)
59
+ end
60
+ rescue Interrupt
61
+ @launcher.stop(shutdown: true)
62
+ exit 0
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def initialize_concurrent_logger
69
+ return unless Shoryuken.logger
70
+
71
+ Concurrent.global_logger = lambda do |level, progname, msg = nil, &block|
72
+ Shoryuken.logger.log(level, msg, progname, &block)
73
+ end
74
+ end
75
+
76
+ def daemonize(options)
77
+ return unless options[:daemon]
78
+
79
+ files_to_reopen = []
80
+ ObjectSpace.each_object(File) do |file|
81
+ files_to_reopen << file unless file.closed?
82
+ end
83
+
84
+ Process.daemon(true, true)
85
+
86
+ files_to_reopen.each do |file|
87
+ begin
88
+ file.reopen file.path, 'a+'
89
+ file.sync = true
90
+ rescue ::Exception
91
+ end
92
+ end
93
+
94
+ [$stdout, $stderr].each do |io|
95
+ File.open(options[:logfile], 'ab') do |f|
96
+ io.reopen(f)
97
+ end
98
+ io.sync = true
99
+ end
100
+ $stdin.reopen('/dev/null')
101
+ end
102
+
103
+ def write_pid(options)
104
+ return unless (path = options[:pidfile])
105
+
106
+ File.open(path, 'w') { |f| f.puts(Process.pid) }
107
+ end
108
+
109
+ def execute_soft_shutdown
110
+ logger.info { 'Received USR1, will soft shutdown down' }
111
+
112
+ @launcher.stop
113
+ fire_event(:quiet, true)
114
+ exit 0
115
+ end
116
+
117
+ def print_threads_backtrace
118
+ Thread.list.each do |thread|
119
+ logger.info { "Thread TID-#{thread.object_id.to_s(36)} #{thread['label']}" }
120
+ if thread.backtrace
121
+ logger.info { thread.backtrace.join("\n") }
122
+ else
123
+ logger.info { '<no backtrace available>' }
124
+ end
125
+ end
126
+ end
127
+
128
+ def handle_signal(sig)
129
+ logger.info { "Got #{sig} signal" }
130
+
131
+ case sig
132
+ when 'USR1' then execute_soft_shutdown
133
+ when 'TTIN' then print_threads_backtrace
134
+ when 'USR2'
135
+ logger.warn { "Received #{sig}, will do nothing. To execute soft shutdown, please send USR1" }
136
+ else
137
+ logger.info { "Received #{sig}, will shutdown down" }
138
+
139
+ raise Interrupt
140
+ end
141
+ end
142
+ end
143
+ end
@@ -1,13 +1,5 @@
1
1
  module Shoryuken
2
2
  module Util
3
- def watchdog(last_words)
4
- yield
5
- rescue => ex
6
- logger.error { last_words }
7
- logger.error { ex }
8
- logger.error { ex.backtrace.join("\n") }
9
- end
10
-
11
3
  def logger
12
4
  Shoryuken.logger
13
5
  end
@@ -1,3 +1,3 @@
1
1
  module Shoryuken
2
- VERSION = '2.1.0'
2
+ VERSION = '3.0.0'.freeze
3
3
  end
@@ -63,7 +63,7 @@ module Shoryuken
63
63
 
64
64
  def normalize_worker_queue!
65
65
  queue = @shoryuken_options['queue']
66
- if queue.respond_to? :call
66
+ if queue.respond_to?(:call)
67
67
  queue = queue.call
68
68
  @shoryuken_options['queue'] = queue
69
69
  end