shoryuken 7.0.1 → 7.0.3

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 (98) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/push.yml +3 -3
  3. data/.github/workflows/specs.yml +37 -10
  4. data/.github/workflows/verify-action-pins.yml +1 -1
  5. data/.gitignore +0 -1
  6. data/.ruby-version +1 -1
  7. data/CHANGELOG.md +120 -0
  8. data/README.md +1 -1
  9. data/bin/{clean_localstack → clean_sqs} +4 -4
  10. data/bin/cli/sqs.rb +7 -0
  11. data/bin/integrations +52 -34
  12. data/docker-compose.yml +4 -20
  13. data/lib/active_job/queue_adapters/shoryuken_adapter.rb +3 -1
  14. data/lib/shoryuken/active_job/current_attributes.rb +35 -7
  15. data/lib/shoryuken/environment_loader.rb +1 -1
  16. data/lib/shoryuken/fetcher.rb +7 -1
  17. data/lib/shoryuken/helpers/timer_task.rb +19 -2
  18. data/lib/shoryuken/launcher.rb +21 -5
  19. data/lib/shoryuken/manager.rb +38 -5
  20. data/lib/shoryuken/middleware/server/auto_extend_visibility.rb +32 -2
  21. data/lib/shoryuken/middleware/server/exponential_backoff_retry.rb +8 -3
  22. data/lib/shoryuken/middleware/server/non_retryable_exception.rb +17 -8
  23. data/lib/shoryuken/options.rb +22 -3
  24. data/lib/shoryuken/polling/strict_priority.rb +26 -14
  25. data/lib/shoryuken/polling/weighted_round_robin.rb +41 -27
  26. data/lib/shoryuken/queue.rb +8 -1
  27. data/lib/shoryuken/util.rb +4 -1
  28. data/lib/shoryuken/version.rb +1 -1
  29. data/lib/shoryuken/worker.rb +5 -1
  30. data/lib/shoryuken.rb +2 -0
  31. data/renovate.json +26 -2
  32. data/spec/integration/active_job/bulk_enqueue/bulk_enqueue_spec.rb +1 -1
  33. data/spec/integration/active_job/current_attributes/bulk_enqueue_spec.rb +1 -1
  34. data/spec/integration/active_job/current_attributes/complex_types_spec.rb +1 -1
  35. data/spec/integration/active_job/current_attributes/cross_job_reset_spec.rb +47 -0
  36. data/spec/integration/active_job/current_attributes/empty_context_spec.rb +1 -1
  37. data/spec/integration/active_job/current_attributes/full_context_spec.rb +1 -1
  38. data/spec/integration/active_job/current_attributes/incremental_persist_spec.rb +76 -0
  39. data/spec/integration/active_job/current_attributes/partial_context_spec.rb +1 -1
  40. data/spec/integration/active_job/custom_attributes/number_attributes_spec.rb +1 -1
  41. data/spec/integration/active_job/custom_attributes/string_attributes_spec.rb +1 -1
  42. data/spec/integration/active_job/fifo_dedup_opt_out/fifo_dedup_opt_out_spec.rb +67 -0
  43. data/spec/integration/active_job/keyword_arguments/keyword_arguments_spec.rb +1 -1
  44. data/spec/integration/active_job/retry/discard_on_spec.rb +1 -1
  45. data/spec/integration/active_job/retry/retry_on_spec.rb +1 -1
  46. data/spec/integration/active_job/roundtrip/roundtrip_spec.rb +2 -2
  47. data/spec/integration/active_job/scheduled/scheduled_spec.rb +1 -1
  48. data/spec/integration/active_record_middleware/active_record_middleware_spec.rb +1 -1
  49. data/spec/integration/auto_delete/auto_delete_spec.rb +1 -1
  50. data/spec/integration/auto_extend_visibility/auto_extend_visibility_spec.rb +1 -1
  51. data/spec/integration/auto_extend_visibility/short_visibility_timeout_spec.rb +52 -0
  52. data/spec/integration/aws_config/aws_config_spec.rb +3 -3
  53. data/spec/integration/batch_processing/batch_processing_spec.rb +1 -1
  54. data/spec/integration/body_parser/json_parser_spec.rb +1 -1
  55. data/spec/integration/body_parser/proc_parser_spec.rb +1 -1
  56. data/spec/integration/body_parser/text_parser_spec.rb +1 -1
  57. data/spec/integration/concurrent_processing/concurrent_processing_spec.rb +1 -1
  58. data/spec/integration/concurrent_processing/processor_accounting_spec.rb +94 -0
  59. data/spec/integration/custom_group_polling_strategy/custom_group_polling_strategy_spec.rb +87 -0
  60. data/spec/integration/dead_letter_queue/dead_letter_queue_spec.rb +1 -1
  61. data/spec/integration/exception_handlers/exception_handlers_spec.rb +1 -1
  62. data/spec/integration/exponential_backoff/exponential_backoff_spec.rb +1 -1
  63. data/spec/integration/fifo_ordering/fifo_max_messages_cap_spec.rb +96 -0
  64. data/spec/integration/fifo_ordering/fifo_ordering_spec.rb +1 -1
  65. data/spec/integration/large_payloads/large_payloads_spec.rb +1 -1
  66. data/spec/integration/launcher/double_graceful_stop_spec.rb +71 -0
  67. data/spec/integration/launcher/embedded_dispatch_error_spec.rb +85 -0
  68. data/spec/integration/launcher/global_executor_preserved_spec.rb +76 -0
  69. data/spec/integration/launcher/graceful_stop_timeout_spec.rb +74 -0
  70. data/spec/integration/launcher/launcher_spec.rb +1 -1
  71. data/spec/integration/message_attributes/message_attributes_spec.rb +1 -1
  72. data/spec/integration/message_operations/message_operations_spec.rb +1 -1
  73. data/spec/integration/message_operations/partial_batch_delete_spec.rb +67 -0
  74. data/spec/integration/non_retryable_exception/non_retryable_exception_spec.rb +2 -2
  75. data/spec/integration/non_retryable_exception/with_retry_intervals_spec.rb +115 -0
  76. data/spec/integration/polling_strategies/polling_strategies_spec.rb +1 -1
  77. data/spec/integration/queue_operations/queue_operations_spec.rb +1 -1
  78. data/spec/integration/retry_behavior/retry_behavior_spec.rb +1 -1
  79. data/spec/integration/strict_priority_polling/strict_priority_polling_spec.rb +1 -1
  80. data/spec/integration/visibility_timeout/visibility_timeout_spec.rb +1 -1
  81. data/spec/integration/worker_enqueueing/worker_enqueueing_spec.rb +1 -1
  82. data/spec/integration/worker_groups/worker_groups_spec.rb +1 -1
  83. data/spec/integration/worker_lifecycle/worker_lifecycle_spec.rb +1 -1
  84. data/spec/integrations_helper.rb +13 -12
  85. data/spec/lib/shoryuken/fetcher_spec.rb +13 -0
  86. data/spec/lib/shoryuken/helpers/timer_task_spec.rb +24 -0
  87. data/spec/lib/shoryuken/launcher_spec.rb +38 -0
  88. data/spec/lib/shoryuken/manager_spec.rb +99 -0
  89. data/spec/lib/shoryuken/middleware/server/auto_extend_visibility_spec.rb +35 -0
  90. data/spec/lib/shoryuken/middleware/server/exponential_backoff_retry_spec.rb +56 -0
  91. data/spec/lib/shoryuken/options_spec.rb +43 -0
  92. data/spec/lib/shoryuken/polling/strict_priority_spec.rb +25 -0
  93. data/spec/lib/shoryuken/polling/weighted_round_robin_spec.rb +50 -0
  94. data/spec/lib/shoryuken/queue_spec.rb +37 -0
  95. data/spec/lib/shoryuken/util_spec.rb +26 -0
  96. data/spec/shared_examples_for_active_job.rb +18 -0
  97. data/spec/spec_helper.rb +26 -7
  98. metadata +29 -3
@@ -43,8 +43,7 @@ module Shoryuken
43
43
  # Don't await here so the timeout below is not delayed
44
44
  stop_new_dispatching
45
45
 
46
- executor.shutdown
47
- executor.kill unless executor.wait_for_termination(Shoryuken.options[:timeout])
46
+ shutdown_executor
48
47
 
49
48
  fire_event(:stopped)
50
49
  end
@@ -61,8 +60,7 @@ module Shoryuken
61
60
  stop_new_dispatching
62
61
  await_dispatching_in_progress
63
62
 
64
- executor.shutdown
65
- executor.wait_for_termination
63
+ shutdown_executor
66
64
 
67
65
  fire_event(:stopped)
68
66
  end
@@ -93,11 +91,29 @@ module Shoryuken
93
91
  @managers.each(&:await_dispatching_in_progress)
94
92
  end
95
93
 
94
+ # Shuts the executor down, giving in-flight workers up to the configured
95
+ # timeout to finish before force-killing them so the process can exit.
96
+ # Used by both the graceful ({#stop}) and immediate ({#stop!}) shutdowns:
97
+ # a graceful stop still waits for workers, but must not block forever on a
98
+ # hung one.
99
+ #
100
+ # @return [void]
101
+ def shutdown_executor
102
+ executor.shutdown
103
+ executor.kill unless executor.wait_for_termination(Shoryuken.options[:timeout])
104
+ end
105
+
96
106
  # Returns the executor for running async operations
97
107
  #
108
+ # Owns a dedicated executor rather than borrowing Concurrent.global_io_executor:
109
+ # {#stop} and {#stop!} shut down and kill this executor, and destroying the
110
+ # process-global pool would break anything else relying on it (including
111
+ # Shoryuken's own ShoryukenConcurrentSendAdapter) and prevent a fresh launcher
112
+ # from starting in the same process.
113
+ #
98
114
  # @return [Concurrent::ExecutorService] the executor service
99
115
  def executor
100
- @_executor ||= Shoryuken.launcher_executor || Concurrent.global_io_executor
116
+ @_executor ||= Shoryuken.launcher_executor || Concurrent::CachedThreadPool.new(auto_terminate: true)
101
117
  end
102
118
 
103
119
  # Starts all managers in parallel futures
@@ -57,6 +57,9 @@ module Shoryuken
57
57
  # There might still be a dispatching on-going, as the response from SQS could take some time
58
58
  # We don't want to stop the process before processing incoming messages, as they would stay "in-flight" for some time on SQS
59
59
  # We use a queue, as the dispatch_loop is running on another thread, and this is a efficient way of communicating between threads.
60
+ # The dispatch loop closes the queue when it observes the stop flag; pop on a closed queue
61
+ # returns immediately, so this stays safe when stop is requested more than once
62
+ # (e.g. TSTP followed by USR1, which both trigger a graceful stop).
60
63
  @dispatching_release_signal.pop
61
64
  end
62
65
 
@@ -74,11 +77,23 @@ module Shoryuken
74
77
  # @return [void]
75
78
  def dispatch_loop
76
79
  if @stop_new_dispatching.true? || !running?
77
- @dispatching_release_signal << 1
80
+ # Close (instead of push) so every pending and future
81
+ # await_dispatching_in_progress call returns, not just the first one
82
+ @dispatching_release_signal.close
78
83
  return
79
84
  end
80
85
 
81
86
  @executor.post { dispatch }
87
+ rescue Concurrent::RejectedExecutionError
88
+ # The executor was shut down between the running? check and the post
89
+ # (e.g. a hard stop racing the dispatch loop); release any waiters as
90
+ # the dispatch chain ends here
91
+ @dispatching_release_signal.close
92
+ rescue StandardError
93
+ # Unexpected error from post (e.g. ThreadError if the OS thread limit is
94
+ # hit); the dispatch chain ends here so release waiters before re-raising
95
+ @dispatching_release_signal.close
96
+ raise
82
97
  end
83
98
 
84
99
  # Dispatches messages from a queue
@@ -129,6 +144,12 @@ module Shoryuken
129
144
  return unless @polling_strategy.respond_to?(:message_processed)
130
145
 
131
146
  @polling_strategy.message_processed(queue)
147
+ rescue => e
148
+ # Swallow (but log) failures from the SQS lookups or the strategy callback:
149
+ # the busy counter was already decremented above and the caller's ensure
150
+ # must not run completion twice
151
+ logger.error { "Processor completion failed for #{queue}: #{e.message}" }
152
+ logger.debug { e.backtrace.join("\n") } unless e.backtrace.nil?
132
153
  end
133
154
 
134
155
  # Assigns a message to a processor
@@ -144,6 +165,11 @@ module Shoryuken
144
165
  @busy_processors.increment
145
166
  fire_utilization_update_event
146
167
 
168
+ # Completion runs in an ensure so it executes exactly once whether
169
+ # processing succeeds or raises. The previous `.then { processor_done }
170
+ # .rescue { processor_done }` chain ran completion twice when
171
+ # processor_done itself raised, double decrementing the busy counter
172
+ # and silently breaking the concurrency limit
147
173
  Concurrent::Promise
148
174
  .execute(executor: @executor) do
149
175
  original_priority = Thread.current.priority
@@ -152,10 +178,9 @@ module Shoryuken
152
178
  Processor.process(queue_name, sqs_msg)
153
179
  ensure
154
180
  Thread.current.priority = original_priority
181
+ processor_done(queue_name)
155
182
  end
156
183
  end
157
- .then { processor_done(queue_name) }
158
- .rescue { processor_done(queue_name) }
159
184
  end
160
185
 
161
186
  # Dispatches a batch of messages from a queue
@@ -208,9 +233,17 @@ module Shoryuken
208
233
  logger.error { "Manager failed: #{ex.message}" }
209
234
  logger.error { ex.backtrace.join("\n") } unless ex.backtrace.nil?
210
235
 
211
- Process.kill('USR1', Process.pid)
212
-
236
+ # Stop this manager first so Launcher#healthy? surfaces the failure to
237
+ # whoever is supervising us (the CLI Runner, or an embedding application).
213
238
  @running.make_false
239
+
240
+ # In server (CLI) mode the Runner traps USR1 and turns it into a graceful
241
+ # shutdown of the whole process, so a process supervisor can restart us.
242
+ # When embedded (no Runner), USR1 keeps its default disposition and would
243
+ # terminate the host process - and any in-flight workers - so we must not
244
+ # send it. The stopped manager above is enough for Launcher#healthy? to
245
+ # report the failure to the embedding application.
246
+ Process.kill('USR1', Process.pid) if Shoryuken.server?
214
247
  end
215
248
 
216
249
  # Fires a utilization update event
@@ -48,8 +48,17 @@ module Shoryuken
48
48
  # @return [Shoryuken::Helpers::TimerTask] the timer task
49
49
  def auto_extend(_worker, queue, sqs_msg, _body)
50
50
  queue_visibility_timeout = Shoryuken::Client.queues(queue).visibility_timeout
51
+ execution_interval = extension_interval(queue_visibility_timeout)
51
52
 
52
- Shoryuken::Helpers::TimerTask.new(execution_interval: queue_visibility_timeout - EXTEND_UPFRONT_SECONDS) do
53
+ unless execution_interval
54
+ logger.warn do
55
+ "Not auto-extending visibility for #{queue}/#{sqs_msg.message_id}: queue visibility " \
56
+ "timeout (#{queue_visibility_timeout}s) is too short. Increase it to use auto_visibility_timeout."
57
+ end
58
+ return nil
59
+ end
60
+
61
+ Shoryuken::Helpers::TimerTask.new(execution_interval: execution_interval) do
53
62
  logger.debug do
54
63
  "Extending message #{queue}/#{sqs_msg.message_id} visibility timeout by #{queue_visibility_timeout}s"
55
64
  end
@@ -61,6 +70,25 @@ module Shoryuken
61
70
  end
62
71
  end
63
72
  end
73
+
74
+ private
75
+
76
+ # Returns a positive interval at which to re-extend the message's
77
+ # visibility before it expires, or nil when the visibility timeout is
78
+ # too short to schedule one. Normally this is EXTEND_UPFRONT_SECONDS
79
+ # before expiry, but for short timeouts (<= EXTEND_UPFRONT_SECONDS) it
80
+ # falls back to half the timeout so the timer still fires in time
81
+ # instead of TimerTask raising on a non-positive interval.
82
+ #
83
+ # @param visibility_timeout [Integer] the queue's visibility timeout in seconds
84
+ # @return [Float, Integer, nil] the execution interval, or nil if too short
85
+ def extension_interval(visibility_timeout)
86
+ return nil if visibility_timeout <= 0
87
+
88
+ interval = visibility_timeout - EXTEND_UPFRONT_SECONDS
89
+ interval = visibility_timeout / 2.0 if interval <= 0
90
+ interval
91
+ end
64
92
  end
65
93
 
66
94
  # Creates and starts a visibility extension timer
@@ -71,7 +99,9 @@ module Shoryuken
71
99
  # @param body [Object] the parsed message body
72
100
  # @return [Shoryuken::Helpers::TimerTask] the started timer
73
101
  def auto_visibility_timer(worker, queue, sqs_msg, body)
74
- MessageVisibilityExtender.new.auto_extend(worker, queue, sqs_msg, body).tap(&:execute)
102
+ timer = MessageVisibilityExtender.new.auto_extend(worker, queue, sqs_msg, body)
103
+ timer&.execute
104
+ timer
75
105
  end
76
106
  end
77
107
  end
@@ -17,8 +17,8 @@ module Shoryuken
17
17
  # @param _body [Object] the parsed message body (unused)
18
18
  # @yield continues to the next middleware in the chain
19
19
  # @return [void]
20
- # @raise [StandardError] re-raises the original exception if retry intervals are not configured
21
- # or if retry limit is exceeded
20
+ # @raise [StandardError] re-raises the original exception if retry intervals are not configured,
21
+ # if retry limit is exceeded, or if the exception is classified as non-retryable
22
22
  def call(worker, _queue, sqs_msg, _body)
23
23
  return yield unless worker.class.exponential_backoff?
24
24
 
@@ -30,7 +30,12 @@ module Shoryuken
30
30
  started_at = Time.now
31
31
  yield
32
32
  rescue => e
33
- retry_intervals = worker.class.get_shoryuken_options['retry_intervals']
33
+ worker_options = worker.class.get_shoryuken_options
34
+ retry_intervals = worker_options['retry_intervals']
35
+
36
+ # Non-retryable exceptions must not be backoff retried; re-raise so the
37
+ # NonRetryableException middleware can delete the message immediately.
38
+ raise if NonRetryableException.non_retryable?(e, worker_options['non_retryable_exceptions'])
34
39
 
35
40
  if retry_intervals.nil? || !handle_failure(sqs_msg, started_at, retry_intervals)
36
41
  # Re-raise the exception if the job is not going to be exponential backoff retried.
@@ -28,6 +28,22 @@ module Shoryuken
28
28
  class NonRetryableException
29
29
  include Util
30
30
 
31
+ # Checks whether an exception is classified as non-retryable for a worker
32
+ #
33
+ # @param exception [Exception] the exception to classify
34
+ # @param non_retryable_exceptions [Array<Class>, #call, nil] the worker's
35
+ # non_retryable_exceptions option: exception classes or a callable
36
+ # @return [Boolean] true if the exception must not be retried
37
+ def self.non_retryable?(exception, non_retryable_exceptions)
38
+ return false unless non_retryable_exceptions
39
+
40
+ if non_retryable_exceptions.respond_to?(:call)
41
+ !!non_retryable_exceptions.call(exception)
42
+ else
43
+ Array(non_retryable_exceptions).any? { |klass| exception.is_a?(klass) }
44
+ end
45
+ end
46
+
31
47
  # Processes a message and handles non-retryable exceptions
32
48
  #
33
49
  # @param worker [Object] the worker instance
@@ -41,14 +57,7 @@ module Shoryuken
41
57
  rescue => e
42
58
  non_retryable_exceptions = worker.class.get_shoryuken_options['non_retryable_exceptions']
43
59
 
44
- return raise unless non_retryable_exceptions
45
-
46
- if non_retryable_exceptions.respond_to?(:call)
47
- return raise unless non_retryable_exceptions.call(e)
48
- else
49
- exception_classes = Array(non_retryable_exceptions)
50
- return raise unless exception_classes.any? { |klass| e.is_a?(klass) }
51
- end
60
+ return raise unless self.class.non_retryable?(e, non_retryable_exceptions)
52
61
 
53
62
  # Handle batch messages
54
63
  messages = sqs_msg.is_a?(Array) ? sqs_msg : [sqs_msg]
@@ -34,7 +34,8 @@ module Shoryuken
34
34
  # @return [Class] the executor class for running workers
35
35
  # @return [Shoryuken::WorkerRegistry] the registry for worker classes
36
36
  # @return [Array<#call>] handlers for processing exceptions
37
- attr_accessor :active_job_queue_name_prefixing, :cache_visibility_timeout,
37
+ attr_accessor :active_job_queue_name_prefixing, :active_job_fifo_message_deduplication,
38
+ :cache_visibility_timeout,
38
39
  :groups, :launcher_executor, :reloader, :enable_reloading,
39
40
  :start_callback, :stop_callback, :worker_executor, :worker_registry,
40
41
  :exception_handlers
@@ -53,6 +54,7 @@ module Shoryuken
53
54
  self.worker_registry = DefaultWorkerRegistry.new
54
55
  self.exception_handlers = [DefaultExceptionHandler]
55
56
  self.active_job_queue_name_prefixing = false
57
+ self.active_job_fifo_message_deduplication = true
56
58
  self.worker_executor = Worker::DefaultExecutor
57
59
  self.cache_visibility_timeout = false
58
60
  self.reloader = proc { |&block| block.call }
@@ -73,14 +75,21 @@ module Shoryuken
73
75
  # @param group [String] the name of the group
74
76
  # @param concurrency [Integer, nil] the number of concurrent workers for the group
75
77
  # @param delay [Float, nil] the delay between polling cycles
78
+ # @param polling_strategy [Class, String, nil] the polling strategy class for the group
76
79
  # @return [Hash] the group configuration
77
- def add_group(group, concurrency = nil, delay: nil)
80
+ # @raise [Errors::InvalidPollingStrategyError] if polling_strategy is not a Class, String, or nil
81
+ def add_group(group, concurrency = nil, delay: nil, polling_strategy: nil)
82
+ unless polling_strategy.nil? || polling_strategy.is_a?(Class) || polling_strategy.is_a?(String)
83
+ raise Errors::InvalidPollingStrategyError, "#{polling_strategy} is not a valid polling_strategy"
84
+ end
85
+
78
86
  concurrency ||= options[:concurrency]
79
87
  delay ||= options[:delay]
80
88
 
81
89
  groups[group] ||= {
82
90
  concurrency: concurrency,
83
91
  delay: delay,
92
+ polling_strategy: polling_strategy,
84
93
  queues: []
85
94
  }
86
95
  end
@@ -109,7 +118,8 @@ module Shoryuken
109
118
  # @param group [String] the name of the group
110
119
  # @return [Class] the polling strategy class to use
111
120
  def polling_strategy(group)
112
- strategy = (group == 'default' ? options : options[:groups].to_h[group]).to_h[:polling_strategy]
121
+ strategy = groups[group].to_h[:polling_strategy] ||
122
+ (group == 'default' ? options : options[:groups].to_h[group]).to_h[:polling_strategy]
113
123
  case strategy
114
124
  when 'WeightedRoundRobin', nil # Default case
115
125
  Polling::WeightedRoundRobin
@@ -294,6 +304,15 @@ module Shoryuken
294
304
  @active_job_queue_name_prefixing
295
305
  end
296
306
 
307
+ # Checks if the ActiveJob adapter should auto-generate a content-based
308
+ # message_deduplication_id for FIFO queues. When disabled, distinct enqueues
309
+ # of the same job class and arguments are no longer silently deduplicated.
310
+ #
311
+ # @return [Boolean] true if FIFO deduplication id generation is enabled
312
+ def active_job_fifo_message_deduplication?
313
+ @active_job_fifo_message_deduplication
314
+ end
315
+
297
316
  private
298
317
 
299
318
  # Creates the default server middleware chain
@@ -22,6 +22,10 @@ module Shoryuken
22
22
  .each_with_object({}) { |queue, h| h[queue] = Time.at(0) }
23
23
 
24
24
  @delay = delay
25
+ # next_queue/messages_found run on the dispatch thread while
26
+ # message_processed runs on processor-completion threads (for FIFO
27
+ # queues), so all access to the shared state is serialized.
28
+ @mutex = Mutex.new
25
29
  # Start queues at 0
26
30
  reset_next_queue
27
31
  end
@@ -30,8 +34,10 @@ module Shoryuken
30
34
  #
31
35
  # @return [QueueConfiguration, nil] the next queue configuration or nil if all paused
32
36
  def next_queue
33
- next_queue = next_active_queue
34
- next_queue.nil? ? nil : QueueConfiguration.new(next_queue, {})
37
+ @mutex.synchronize do
38
+ next_queue = next_active_queue
39
+ next_queue.nil? ? nil : QueueConfiguration.new(next_queue, {})
40
+ end
35
41
  end
36
42
 
37
43
  # Handles the result of polling a queue
@@ -40,10 +46,12 @@ module Shoryuken
40
46
  # @param messages_found [Integer] number of messages found
41
47
  # @return [void]
42
48
  def messages_found(queue, messages_found)
43
- if messages_found == 0
44
- pause(queue)
45
- else
46
- reset_next_queue
49
+ @mutex.synchronize do
50
+ if messages_found == 0
51
+ pause(queue)
52
+ else
53
+ reset_next_queue
54
+ end
47
55
  end
48
56
  end
49
57
 
@@ -51,11 +59,13 @@ module Shoryuken
51
59
  #
52
60
  # @return [Array<Array>] array of [queue_name, priority] pairs
53
61
  def active_queues
54
- @queues
55
- .reverse
56
- .map.with_index(1)
57
- .reject { |q, _| queue_paused?(q) }
58
- .reverse
62
+ @mutex.synchronize do
63
+ @queues
64
+ .reverse
65
+ .map.with_index(1)
66
+ .reject { |q, _| queue_paused?(q) }
67
+ .reverse
68
+ end
59
69
  end
60
70
 
61
71
  # Called when a message from a queue has been processed
@@ -63,9 +73,11 @@ module Shoryuken
63
73
  # @param queue [String] the queue name
64
74
  # @return [void]
65
75
  def message_processed(queue)
66
- if queue_paused?(queue)
67
- logger.debug "Unpausing #{queue}"
68
- @paused_until[queue] = Time.at 0
76
+ @mutex.synchronize do
77
+ if queue_paused?(queue)
78
+ logger.debug "Unpausing #{queue}"
79
+ @paused_until[queue] = Time.at(0)
80
+ end
69
81
  end
70
82
  end
71
83
 
@@ -15,18 +15,26 @@ module Shoryuken
15
15
  @queues = queues.dup.uniq
16
16
  @paused_queues = []
17
17
  @delay = delay
18
+ # next_queue/messages_found run on the dispatch thread while
19
+ # message_processed runs on processor-completion threads (for FIFO
20
+ # queues), so all access to the shared state is serialized.
21
+ @mutex = Mutex.new
18
22
  end
19
23
 
20
24
  # Returns the next queue to poll in round-robin order
21
25
  #
22
26
  # @return [QueueConfiguration, nil] the next queue configuration or nil if all paused
23
27
  def next_queue
24
- unpause_queues
25
- queue = @queues.shift
26
- return nil if queue.nil?
27
-
28
- @queues << queue
29
- QueueConfiguration.new(queue, {})
28
+ @mutex.synchronize do
29
+ unpause_queues
30
+ queue = @queues.shift
31
+ if queue.nil?
32
+ nil
33
+ else
34
+ @queues << queue
35
+ QueueConfiguration.new(queue, {})
36
+ end
37
+ end
30
38
  end
31
39
 
32
40
  # Handles the result of polling a queue, adjusting weight if messages were found
@@ -35,16 +43,17 @@ module Shoryuken
35
43
  # @param messages_found [Integer] number of messages found
36
44
  # @return [void]
37
45
  def messages_found(queue, messages_found)
38
- if messages_found == 0
39
- pause(queue)
40
- return
41
- end
42
-
43
- maximum_weight = maximum_queue_weight(queue)
44
- current_weight = current_queue_weight(queue)
45
- if maximum_weight > current_weight
46
- logger.info { "Increasing #{queue} weight to #{current_weight + 1}, max: #{maximum_weight}" }
47
- @queues << queue
46
+ @mutex.synchronize do
47
+ if messages_found == 0
48
+ pause(queue)
49
+ else
50
+ maximum_weight = maximum_queue_weight(queue)
51
+ current_weight = current_queue_weight(queue)
52
+ if maximum_weight > current_weight
53
+ logger.info { "Increasing #{queue} weight to #{current_weight + 1}, max: #{maximum_weight}" }
54
+ @queues << queue
55
+ end
56
+ end
48
57
  end
49
58
  end
50
59
 
@@ -52,7 +61,7 @@ module Shoryuken
52
61
  #
53
62
  # @return [Array<Array>] array of [queue_name, weight] pairs
54
63
  def active_queues
55
- unparse_queues(@queues)
64
+ @mutex.synchronize { unparse_queues(@queues) }
56
65
  end
57
66
 
58
67
  # Called when a message from a queue has been processed
@@ -60,10 +69,10 @@ module Shoryuken
60
69
  # @param queue [String] the queue name
61
70
  # @return [void]
62
71
  def message_processed(queue)
63
- paused_queue = @paused_queues.find { |_time, name| name == queue }
64
- return unless paused_queue
65
-
66
- paused_queue[0] = Time.at 0
72
+ @mutex.synchronize do
73
+ paused_queue = @paused_queues.find { |_time, name| name == queue }
74
+ paused_queue[0] = Time.at(0) if paused_queue
75
+ end
67
76
  end
68
77
 
69
78
  private
@@ -79,16 +88,21 @@ module Shoryuken
79
88
  logger.debug "Paused #{queue}"
80
89
  end
81
90
 
82
- # Unpauses queues whose delay has expired
91
+ # Unpauses the first queue whose delay has expired.
92
+ #
93
+ # Scans for any expired entry rather than only the head of the list:
94
+ # message_processed marks a queue ready by setting its time to the epoch,
95
+ # and that entry may sit behind an earlier-paused queue that is still
96
+ # paused - checking only the head would leave the ready queue stuck.
83
97
  #
84
98
  # @return [void]
85
99
  def unpause_queues
86
- return if @paused_queues.empty?
87
- return if Time.now < @paused_queues.first[0]
100
+ index = @paused_queues.index { |time, _name| time <= Time.now }
101
+ return if index.nil?
88
102
 
89
- pause = @paused_queues.shift
90
- @queues << pause[1]
91
- logger.debug "Unpaused #{pause[1]}"
103
+ paused = @paused_queues.delete_at(index)
104
+ @queues << paused[1]
105
+ logger.debug "Unpaused #{paused[1]}"
92
106
  end
93
107
 
94
108
  # Returns the current weight of a queue in the active rotation
@@ -52,11 +52,18 @@ module Shoryuken
52
52
  failed_messages = client.delete_message_batch(
53
53
  options.merge(queue_url: url)
54
54
  ).failed || []
55
- failed_messages.any? do |failure|
55
+
56
+ # Log every failure (not just the first) and return a plain boolean.
57
+ # The previous `any? { logger.error ... }` short-circuited after the first
58
+ # failure - so the rest went unlogged - and only returned truthy because
59
+ # Logger#error happens to return true.
60
+ failed_messages.each do |failure|
56
61
  logger.error do
57
62
  "Could not delete #{failure.id}, code: '#{failure.code}', message: '#{failure.message}', sender_fault: #{failure.sender_fault}"
58
63
  end
59
64
  end
65
+
66
+ failed_messages.any?
60
67
  end
61
68
 
62
69
  # Sends a single message to the queue
@@ -20,7 +20,10 @@ module Shoryuken
20
20
  def fire_event(event, reverse = false, event_options = {})
21
21
  logger.debug { "Firing '#{event}' lifecycle event" }
22
22
  arr = Shoryuken.options[:lifecycle_events][event]
23
- arr.reverse! if reverse
23
+ # reverse (not reverse!) so the stored handler array keeps its original
24
+ # order: events fired more than once with reverse: true (e.g. :shutdown
25
+ # via stop then stop!) must not alternate their handler order.
26
+ arr = arr.reverse if reverse
24
27
  arr.each do |block|
25
28
  block.call(event_options)
26
29
  rescue => e
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Shoryuken
4
4
  # Current version of the Shoryuken gem
5
- VERSION = '7.0.1'
5
+ VERSION = '7.0.3'
6
6
  end
@@ -224,7 +224,11 @@ module Shoryuken
224
224
  #
225
225
  # @example Configuring exponential backoff
226
226
  # shoryuken_options retry_intervals: [1, 5, 25, 125, 625]
227
- # # Will retry after 1s, 5s, 25s, 125s, then 625s before giving up
227
+ # # Retries after 1s, 5s, 25s, 125s, then 625s for every later attempt.
228
+ # # Shoryuken does not stop retrying on its own once the intervals are
229
+ # # exhausted - it keeps reusing the last interval. Configure an SQS
230
+ # # redrive policy (maxReceiveCount) to send exhausted messages to a
231
+ # # dead-letter queue.
228
232
  #
229
233
  # @see #shoryuken_options Documentation for configuring retry_intervals
230
234
  def exponential_backoff?
data/lib/shoryuken.rb CHANGED
@@ -58,6 +58,8 @@ module Shoryuken
58
58
  :stop_callback=,
59
59
  :active_job_queue_name_prefixing?,
60
60
  :active_job_queue_name_prefixing=,
61
+ :active_job_fifo_message_deduplication?,
62
+ :active_job_fifo_message_deduplication=,
61
63
  :sqs_client,
62
64
  :sqs_client=,
63
65
  :sqs_client_receive_message_opts,
data/renovate.json CHANGED
@@ -4,6 +4,16 @@
4
4
  "config:recommended"
5
5
  ],
6
6
  "minimumReleaseAge": "7 days",
7
+ "includePaths": [
8
+ ".ruby-version",
9
+ "Gemfile",
10
+ "Gemfile.lint",
11
+ "shoryuken.gemspec",
12
+ "spec/gemfiles/**",
13
+ "spec/integration/**/Gemfile",
14
+ ".github/workflows/**",
15
+ "docker-compose.yml"
16
+ ],
7
17
  "github-actions": {
8
18
  "enabled": true,
9
19
  "pinDigests": true
@@ -46,7 +56,21 @@
46
56
  "ruby/setup-ruby",
47
57
  "ruby"
48
58
  ],
49
- "groupName": "ruby setup"
59
+ "groupName": "ruby setup",
60
+ "internalChecksFilter": "strict"
61
+ },
62
+ {
63
+ "description": "Let setup-ruby pass age gate before ruby so it is ready when the group PR is created",
64
+ "matchPackageNames": [
65
+ "ruby/setup-ruby"
66
+ ],
67
+ "minimumReleaseAge": "5 days"
50
68
  }
51
- ]
69
+ ],
70
+ "lockFileMaintenance": {
71
+ "enabled": true,
72
+ "schedule": [
73
+ "before 4am on the first day of the month"
74
+ ]
75
+ }
52
76
  }
@@ -3,7 +3,7 @@
3
3
  # Bulk enqueue integration test
4
4
  # Tests perform_all_later with the new enqueue_all method using SQS batch API
5
5
 
6
- setup_localstack
6
+ setup_sqs
7
7
  setup_active_job
8
8
 
9
9
  queue_name = DT.queue
@@ -2,7 +2,7 @@
2
2
 
3
3
  # CurrentAttributes are persisted correctly when using bulk enqueue (perform_all_later)
4
4
 
5
- setup_localstack
5
+ setup_sqs
6
6
  setup_active_job
7
7
 
8
8
  require 'active_support/current_attributes'
@@ -2,7 +2,7 @@
2
2
 
3
3
  # CurrentAttributes with complex data types (hashes, arrays, symbols) are serialized and restored
4
4
 
5
- setup_localstack
5
+ setup_sqs
6
6
  setup_active_job
7
7
 
8
8
  require 'active_support/current_attributes'