shoryuken 7.0.2 → 7.0.4

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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/push.yml +3 -3
  3. data/.github/workflows/specs.yml +32 -5
  4. data/.github/workflows/verify-action-pins.yml +1 -1
  5. data/.ruby-version +1 -1
  6. data/.yard-lint.yml +29 -1
  7. data/CHANGELOG.md +171 -0
  8. data/Gemfile.lint.lock +8 -7
  9. data/bin/cli/sqs.rb +67 -3
  10. data/bin/integrations +52 -34
  11. data/lib/active_job/extensions.rb +5 -8
  12. data/lib/active_job/queue_adapters/shoryuken_adapter.rb +6 -5
  13. data/lib/shoryuken/active_job/current_attributes.rb +33 -7
  14. data/lib/shoryuken/body_parser.rb +1 -2
  15. data/lib/shoryuken/client.rb +15 -6
  16. data/lib/shoryuken/default_exception_handler.rb +2 -3
  17. data/lib/shoryuken/errors.rb +2 -4
  18. data/lib/shoryuken/fetcher.rb +7 -1
  19. data/lib/shoryuken/helpers/atomic_counter.rb +6 -9
  20. data/lib/shoryuken/helpers/atomic_hash.rb +9 -15
  21. data/lib/shoryuken/helpers/hash_utils.rb +5 -7
  22. data/lib/shoryuken/helpers/string_utils.rb +6 -8
  23. data/lib/shoryuken/helpers/timer_task.rb +19 -2
  24. data/lib/shoryuken/inline_message.rb +3 -4
  25. data/lib/shoryuken/launcher.rb +19 -8
  26. data/lib/shoryuken/manager.rb +65 -6
  27. data/lib/shoryuken/message.rb +2 -3
  28. data/lib/shoryuken/middleware/chain.rb +7 -13
  29. data/lib/shoryuken/middleware/entry.rb +1 -2
  30. data/lib/shoryuken/middleware/server/auto_extend_visibility.rb +30 -2
  31. data/lib/shoryuken/middleware/server/exponential_backoff_retry.rb +40 -18
  32. data/lib/shoryuken/middleware/server/non_retryable_exception.rb +17 -8
  33. data/lib/shoryuken/middleware/server/timing.rb +2 -3
  34. data/lib/shoryuken/options.rb +37 -4
  35. data/lib/shoryuken/polling/base_strategy.rb +16 -23
  36. data/lib/shoryuken/polling/queue_configuration.rb +8 -12
  37. data/lib/shoryuken/polling/strict_priority.rb +26 -14
  38. data/lib/shoryuken/polling/weighted_round_robin.rb +40 -27
  39. data/lib/shoryuken/queue.rb +41 -4
  40. data/lib/shoryuken/util.rb +4 -1
  41. data/lib/shoryuken/version.rb +1 -1
  42. data/lib/shoryuken/worker/default_executor.rb +11 -5
  43. data/lib/shoryuken/worker/inline_executor.rb +10 -5
  44. data/lib/shoryuken/worker.rb +15 -13
  45. data/lib/shoryuken.rb +6 -3
  46. data/renovate.json +16 -2
  47. data/shoryuken.gemspec +9 -0
  48. data/spec/integration/active_job/current_attributes/cross_job_reset_spec.rb +47 -0
  49. data/spec/integration/active_job/current_attributes/incremental_persist_spec.rb +76 -0
  50. data/spec/integration/active_job/fifo_dedup_opt_out/fifo_dedup_opt_out_spec.rb +67 -0
  51. data/spec/integration/auto_extend_visibility/short_visibility_timeout_spec.rb +52 -0
  52. data/spec/integration/cli/find_all_spec.rb +120 -0
  53. data/spec/integration/concurrent_processing/processor_accounting_spec.rb +94 -0
  54. data/spec/integration/fifo_ordering/fifo_max_messages_cap_spec.rb +96 -0
  55. data/spec/integration/launcher/double_graceful_stop_spec.rb +71 -0
  56. data/spec/integration/launcher/embedded_dispatch_error_spec.rb +85 -0
  57. data/spec/integration/launcher/global_executor_preserved_spec.rb +76 -0
  58. data/spec/integration/launcher/graceful_stop_timeout_spec.rb +74 -0
  59. data/spec/integration/message_operations/partial_batch_delete_spec.rb +67 -0
  60. data/spec/integration/non_retryable_exception/non_retryable_exception_spec.rb +1 -1
  61. data/spec/integration/non_retryable_exception/with_retry_intervals_spec.rb +115 -0
  62. data/spec/integrations_helper.rb +10 -9
  63. data/spec/lib/shoryuken/client_spec.rb +62 -0
  64. data/spec/lib/shoryuken/fetcher_spec.rb +13 -0
  65. data/spec/lib/shoryuken/helpers/timer_task_spec.rb +24 -0
  66. data/spec/lib/shoryuken/launcher_spec.rb +38 -0
  67. data/spec/lib/shoryuken/manager_spec.rb +147 -0
  68. data/spec/lib/shoryuken/middleware/server/auto_extend_visibility_spec.rb +35 -0
  69. data/spec/lib/shoryuken/middleware/server/exponential_backoff_retry_spec.rb +88 -0
  70. data/spec/lib/shoryuken/polling/strict_priority_spec.rb +25 -0
  71. data/spec/lib/shoryuken/polling/weighted_round_robin_spec.rb +50 -0
  72. data/spec/lib/shoryuken/queue_spec.rb +123 -0
  73. data/spec/lib/shoryuken/util_spec.rb +26 -0
  74. data/spec/lib/shoryuken/worker/default_executor_spec.rb +13 -0
  75. data/spec/lib/shoryuken/worker/inline_executor_spec.rb +12 -0
  76. data/spec/shared_examples_for_active_job.rb +18 -0
  77. data/spec/spec_helper.rb +40 -20
  78. metadata +35 -3
@@ -1,10 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Shoryuken
4
- # Middleware provides a way to wrap message processing with custom logic,
5
- # similar to Rack middleware in web applications. Middleware runs on the server
6
- # side and can perform setup, teardown, error handling, and monitoring around
7
- # job execution.
4
+ # Middleware provides a way to wrap message processing with custom logic, similar to Rack middleware in web applications.
5
+ # Middleware runs on the server side and can perform setup, teardown, error handling, and monitoring around job execution.
8
6
  #
9
7
  # Middleware classes must implement a `call` method that accepts the worker instance,
10
8
  # queue name, and SQS message, and must yield to continue the middleware chain.
@@ -84,9 +82,8 @@ module Shoryuken
84
82
  # @see Shoryuken::Middleware::Chain Middleware chain management
85
83
  # @see https://github.com/ruby-shoryuken/shoryuken/wiki/Middleware Comprehensive middleware guide
86
84
  module Middleware
87
- # Manages a chain of middleware classes that will be instantiated and invoked
88
- # in sequence around message processing. Provides methods for adding, removing,
89
- # and reordering middleware.
85
+ # Manages a chain of middleware classes that will be instantiated and invoked in sequence around message processing.
86
+ # Provides methods for adding, removing, and reordering middleware.
90
87
  class Chain
91
88
  # @return [Array<Entry>] The ordered list of middleware entries
92
89
  attr_reader :entries
@@ -121,8 +118,7 @@ module Shoryuken
121
118
  entries.delete_if { |entry| entry.klass == klass }
122
119
  end
123
120
 
124
- # Adds middleware to the end of the chain. Does nothing if the middleware
125
- # class is already present in the chain.
121
+ # Adds middleware to the end of the chain. Does nothing if the middleware class is already present in the chain.
126
122
  #
127
123
  # @param klass [Class] The middleware class to add
128
124
  # @param args [Array] Arguments to pass to the middleware constructor
@@ -132,8 +128,7 @@ module Shoryuken
132
128
  entries << Entry.new(klass, *args) unless exists?(klass)
133
129
  end
134
130
 
135
- # Adds middleware to the beginning of the chain. Does nothing if the middleware
136
- # class is already present in the chain.
131
+ # Adds middleware to the beginning of the chain. Does nothing if the middleware class is already present in the chain.
137
132
  #
138
133
  # @param klass [Class] The middleware class to prepend
139
134
  # @param args [Array] Arguments to pass to the middleware constructor
@@ -195,8 +190,7 @@ module Shoryuken
195
190
  entries.clear
196
191
  end
197
192
 
198
- # Invokes the middleware chain with the given arguments.
199
- # Each middleware's call method will be invoked in sequence,
193
+ # Invokes the middleware chain with the given arguments. Each middleware's call method will be invoked in sequence,
200
194
  # with control passed through yielding.
201
195
  #
202
196
  # @param args [Array] arguments to pass to each middleware
@@ -2,8 +2,7 @@
2
2
 
3
3
  module Shoryuken
4
4
  module Middleware
5
- # Represents an entry in a middleware chain, storing the middleware class
6
- # and any arguments needed for its instantiation.
5
+ # Represents an entry in a middleware chain, storing the middleware class and any arguments needed for its instantiation.
7
6
  #
8
7
  # @api private
9
8
  class Entry
@@ -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,23 @@ 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 visibility before it expires, or nil when the
77
+ # visibility timeout is too short to schedule one. Normally this is EXTEND_UPFRONT_SECONDS before expiry, but for
78
+ # short timeouts (<= EXTEND_UPFRONT_SECONDS) it falls back to half the timeout so the timer still fires in time
79
+ # instead of TimerTask raising on a non-positive interval.
80
+ #
81
+ # @param visibility_timeout [Integer] the queue's visibility timeout in seconds
82
+ # @return [Float, Integer, nil] the execution interval, or nil if too short
83
+ def extension_interval(visibility_timeout)
84
+ return nil if visibility_timeout <= 0
85
+
86
+ interval = visibility_timeout - EXTEND_UPFRONT_SECONDS
87
+ interval = visibility_timeout / 2.0 if interval <= 0
88
+ interval
89
+ end
64
90
  end
65
91
 
66
92
  # Creates and starts a visibility extension timer
@@ -71,7 +97,9 @@ module Shoryuken
71
97
  # @param body [Object] the parsed message body
72
98
  # @return [Shoryuken::Helpers::TimerTask] the started timer
73
99
  def auto_visibility_timer(worker, queue, sqs_msg, body)
74
- MessageVisibilityExtender.new.auto_extend(worker, queue, sqs_msg, body).tap(&:execute)
100
+ timer = MessageVisibilityExtender.new.auto_extend(worker, queue, sqs_msg, body)
101
+ timer&.execute
102
+ timer
75
103
  end
76
104
  end
77
105
  end
@@ -3,9 +3,8 @@
3
3
  module Shoryuken
4
4
  module Middleware
5
5
  module Server
6
- # Middleware that implements exponential backoff retry for failed messages.
7
- # When a job fails, the message visibility timeout is adjusted based on
8
- # configured retry intervals.
6
+ # Middleware that implements exponential backoff retry for failed messages. When a job fails, the message visibility
7
+ # timeout is adjusted based on configured retry intervals.
9
8
  class ExponentialBackoffRetry
10
9
  include Util
11
10
 
@@ -17,8 +16,8 @@ module Shoryuken
17
16
  # @param _body [Object] the parsed message body (unused)
18
17
  # @yield continues to the next middleware in the chain
19
18
  # @return [void]
20
- # @raise [StandardError] re-raises the original exception if retry intervals are not configured
21
- # or if retry limit is exceeded
19
+ # @raise [StandardError] re-raises the original exception if retry intervals are not configured,
20
+ # if retry limit is exceeded, or if the exception is classified as non-retryable
22
21
  def call(worker, _queue, sqs_msg, _body)
23
22
  return yield unless worker.class.exponential_backoff?
24
23
 
@@ -28,19 +27,31 @@ module Shoryuken
28
27
  end
29
28
 
30
29
  started_at = Time.now
31
- yield
32
- rescue => e
33
- retry_intervals = worker.class.get_shoryuken_options['retry_intervals']
34
30
 
35
- if retry_intervals.nil? || !handle_failure(sqs_msg, started_at, retry_intervals)
36
- # Re-raise the exception if the job is not going to be exponential backoff retried.
37
- # This allows custom middleware (like exception notifiers) to be aware of the unhandled failure.
38
- raise
39
- end
31
+ # Scope the rescue to the single-message yield only. A method-level
32
+ # rescue would also catch the batch `return yield` above, then route the
33
+ # Array into handle_failure (which calls #attributes/#message_id on it),
34
+ # raising a NoMethodError that masks the original worker error.
35
+ begin
36
+ yield
37
+ rescue => e
38
+ worker_options = worker.class.get_shoryuken_options
39
+ retry_intervals = worker_options['retry_intervals']
40
+
41
+ # Non-retryable exceptions must not be backoff retried; re-raise so the
42
+ # NonRetryableException middleware can delete the message immediately.
43
+ raise if NonRetryableException.non_retryable?(e, worker_options['non_retryable_exceptions'])
40
44
 
41
- logger.warn { "Message #{sqs_msg.message_id} will attempt retry due to error: #{e.message}" }
42
- # since we didn't raise, lets log the backtrace for debugging purposes.
43
- logger.debug { e.backtrace.join("\n") } unless e.backtrace.nil?
45
+ if retry_intervals.nil? || !handle_failure(sqs_msg, started_at, retry_intervals)
46
+ # Re-raise the exception if the job is not going to be exponential backoff retried.
47
+ # This allows custom middleware (like exception notifiers) to be aware of the unhandled failure.
48
+ raise
49
+ end
50
+
51
+ logger.warn { "Message #{sqs_msg.message_id} will attempt retry due to error: #{e.message}" }
52
+ # since we didn't raise, lets log the backtrace for debugging purposes.
53
+ logger.debug { e.backtrace.join("\n") } unless e.backtrace.nil?
54
+ end
44
55
  end
45
56
 
46
57
  private
@@ -64,11 +75,14 @@ module Shoryuken
64
75
  #
65
76
  # @param interval [Integer] the desired interval
66
77
  # @param started_at [Time] when processing started
67
- # @return [Integer] the capped visibility timeout
78
+ # @return [Integer] the capped visibility timeout (never negative)
68
79
  def next_visibility_timeout(interval, started_at)
69
80
  max_timeout = 43_200 - (Time.now - started_at).ceil - 1
70
81
  interval = max_timeout if interval > max_timeout
71
- interval.to_i
82
+ # Never go negative: a job that ran longer than the 12h SQS ceiling
83
+ # drives max_timeout below zero, and change_message_visibility rejects
84
+ # a negative timeout. Clamp to 0 (retry as soon as possible) instead.
85
+ [interval.to_i, 0].max
72
86
  end
73
87
 
74
88
  # Handles a message failure by adjusting visibility timeout
@@ -87,6 +101,14 @@ module Shoryuken
87
101
  logger.info { "Message #{sqs_msg.message_id} failed, will be retried in #{interval} seconds" }
88
102
 
89
103
  true
104
+ rescue => e
105
+ # Rescheduling itself failed (e.g. an expired receipt handle, or a
106
+ # transient SQS error). Don't let that exception escape and mask the
107
+ # original worker failure - log it and report "not retried" so the
108
+ # caller re-raises the original error and the message falls back to the
109
+ # queue's default visibility timeout.
110
+ logger.warn { "Failed to set backoff visibility timeout for #{sqs_msg.message_id}: #{e.message}" }
111
+ false
90
112
  end
91
113
  end
92
114
  end
@@ -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]
@@ -3,9 +3,8 @@
3
3
  module Shoryuken
4
4
  module Middleware
5
5
  module Server
6
- # Middleware that logs timing information for message processing.
7
- # Records start time, completion time, and warns if processing
8
- # exceeds the queue's visibility timeout.
6
+ # Middleware that logs timing information for message processing. Records start time, completion time, and warns if
7
+ # processing exceeds the queue's visibility timeout.
9
8
  class Timing
10
9
  include Util
11
10
 
@@ -1,9 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Shoryuken
4
- # Stores and manages all Shoryuken configuration options.
5
- # This class is used internally to hold settings for workers, queues,
6
- # middleware, and other runtime configurations.
4
+ # Stores and manages all Shoryuken configuration options. This class is used internally to hold settings for workers,
5
+ # queues, middleware, and other runtime configurations.
7
6
  class Options
8
7
  # Default configuration values for Shoryuken
9
8
  DEFAULTS = {
@@ -34,7 +33,9 @@ module Shoryuken
34
33
  # @return [Class] the executor class for running workers
35
34
  # @return [Shoryuken::WorkerRegistry] the registry for worker classes
36
35
  # @return [Array<#call>] handlers for processing exceptions
37
- attr_accessor :active_job_queue_name_prefixing, :cache_visibility_timeout,
36
+ attr_accessor :active_job_queue_name_prefixing, :active_job_fifo_message_deduplication,
37
+ :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,8 @@ 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
58
+ self.fifo_message_deduplication = true
56
59
  self.worker_executor = Worker::DefaultExecutor
57
60
  self.cache_visibility_timeout = false
58
61
  self.reloader = proc { |&block| block.call }
@@ -302,6 +305,36 @@ module Shoryuken
302
305
  @active_job_queue_name_prefixing
303
306
  end
304
307
 
308
+ # Checks if the ActiveJob adapter should auto-generate a content-based message_deduplication_id for FIFO queues. When
309
+ # disabled, distinct enqueues 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
+
316
+ # Checks whether Shoryuken auto-generates a content-based
317
+ # message_deduplication_id for raw FIFO sends (Worker.perform_async,
318
+ # Queue#send_message / #send_messages). When disabled, distinct sends of an
319
+ # identical body are no longer silently deduplicated by SQS - you must then
320
+ # provide a message_deduplication_id yourself or enable
321
+ # ContentBasedDeduplication on the queue.
322
+ #
323
+ # ActiveJob enqueues are primarily controlled by
324
+ # #active_job_fifo_message_deduplication?. When that is enabled the adapter
325
+ # sets a content-based message_deduplication_id itself (from the body minus
326
+ # job_id/enqueued_at) before the raw send, so this flag is a no-op for them.
327
+ # When it is disabled the adapter omits the id and this flag governs the
328
+ # fallback: left enabled (the default) each enqueue still gets a hash of the
329
+ # full serialized body - which includes the per-enqueue job_id, so distinct
330
+ # enqueues are NOT deduplicated - while disabling it drops the id entirely
331
+ # (then requiring an explicit id or the queue's ContentBasedDeduplication).
332
+ #
333
+ # @return [Boolean] true if FIFO deduplication id generation is enabled
334
+ def fifo_message_deduplication?
335
+ @fifo_message_deduplication
336
+ end
337
+
305
338
  private
306
339
 
307
340
  # Creates the default server middleware chain
@@ -5,10 +5,9 @@ module Shoryuken
5
5
  module Polling
6
6
  # Abstract base class for queue polling strategies.
7
7
  #
8
- # This class defines the interface that all polling strategies must implement
9
- # to manage queue selection and message flow control in Shoryuken workers.
10
- # Polling strategies determine which queue to fetch messages from next and
11
- # how to handle scenarios where queues have no messages available.
8
+ # This class defines the interface that all polling strategies must implement to manage queue selection and message flow
9
+ # control in Shoryuken workers. Polling strategies determine which queue to fetch messages from next and how to handle
10
+ # scenarios where queues have no messages available.
12
11
  #
13
12
  # @abstract Subclass and override {#next_queue}, {#messages_found}, and {#active_queues}
14
13
  # to implement a custom polling strategy.
@@ -42,9 +41,8 @@ module Shoryuken
42
41
 
43
42
  # Returns the next queue to poll for messages.
44
43
  #
45
- # This method should return a QueueConfiguration object representing
46
- # the next queue that should be polled for messages, or nil if no
47
- # queues are currently available for polling.
44
+ # This method should return a QueueConfiguration object representing the next queue that should be polled for messages,
45
+ # or nil if no queues are currently available for polling.
48
46
  #
49
47
  # @abstract Subclasses must implement this method
50
48
  # @return [QueueConfiguration, nil] Next queue to poll, or nil if none available
@@ -55,10 +53,9 @@ module Shoryuken
55
53
 
56
54
  # Called when messages are found (or not found) in a queue.
57
55
  #
58
- # This method is invoked after polling a queue to inform the strategy
59
- # about the number of messages that were retrieved. Strategies can use
60
- # this information to make decisions about future polling behavior,
61
- # such as pausing empty queues or adjusting queue weights.
56
+ # This method is invoked after polling a queue to inform the strategy about the number of messages that were retrieved.
57
+ # Strategies can use this information to make decisions about future polling behavior, such as pausing empty queues or
58
+ # adjusting queue weights.
62
59
  #
63
60
  # @abstract Subclasses must implement this method
64
61
  # @param _queue [String] The name of the queue that was polled
@@ -70,9 +67,8 @@ module Shoryuken
70
67
 
71
68
  # Called when a message from a queue has been processed.
72
69
  #
73
- # This optional callback is invoked after a message has been successfully
74
- # processed by a worker. Strategies can use this information for cleanup
75
- # or to adjust their polling behavior.
70
+ # This optional callback is invoked after a message has been successfully processed by a worker. Strategies can use
71
+ # this information for cleanup or to adjust their polling behavior.
76
72
  #
77
73
  # @param _queue [String] The name of the queue whose message was processed
78
74
  # @return [void]
@@ -80,9 +76,8 @@ module Shoryuken
80
76
 
81
77
  # Returns the list of currently active queues.
82
78
  #
83
- # This method should return an array representing the queues that are
84
- # currently active and available for polling. The format may vary by
85
- # strategy implementation.
79
+ # This method should return an array representing the queues that are currently active and available for polling. The
80
+ # format may vary by strategy implementation.
86
81
  #
87
82
  # @abstract Subclasses must implement this method
88
83
  # @return [Array] List of active queues
@@ -93,9 +88,8 @@ module Shoryuken
93
88
 
94
89
  # Compares this strategy with another object for equality.
95
90
  #
96
- # Two strategies are considered equal if they have the same active queues.
97
- # This method also supports comparison with Array objects for backward
98
- # compatibility.
91
+ # Two strategies are considered equal if they have the same active queues. This method also supports comparison with
92
+ # Array objects for backward compatibility.
99
93
  #
100
94
  # @param other [Object] Object to compare with
101
95
  # @return [Boolean] true if strategies are equivalent
@@ -114,9 +108,8 @@ module Shoryuken
114
108
 
115
109
  # Returns the delay time for pausing empty queues.
116
110
  #
117
- # This method returns the amount of time (in seconds) that empty queues
118
- # should be paused before being polled again. The delay can be set at
119
- # the strategy level or falls back to the global Shoryuken delay setting.
111
+ # This method returns the amount of time (in seconds) that empty queues should be paused before being polled again. The
112
+ # delay can be set at the strategy level or falls back to the global Shoryuken delay setting.
120
113
  #
121
114
  # @return [Float] Delay time in seconds
122
115
  def delay
@@ -4,9 +4,8 @@ module Shoryuken
4
4
  module Polling
5
5
  # Configuration object representing a queue and its associated options.
6
6
  #
7
- # This class encapsulates a queue name along with any polling-specific
8
- # options or metadata. It provides a structured way to pass queue
9
- # information between polling strategies and the message fetching system.
7
+ # This class encapsulates a queue name along with any polling-specific options or metadata. It provides a structured way
8
+ # to pass queue information between polling strategies and the message fetching system.
10
9
  #
11
10
  # The class extends Struct to provide attribute accessors for name and options
12
11
  # while adding custom behavior for equality comparison and string representation.
@@ -32,9 +31,8 @@ module Shoryuken
32
31
  QueueConfiguration = Struct.new(:name, :options) do
33
32
  # Generates a hash value based on the queue name.
34
33
  #
35
- # This method ensures that QueueConfiguration objects can be used
36
- # as hash keys and that configurations with the same queue name
37
- # will have the same hash value regardless of their options.
34
+ # This method ensures that QueueConfiguration objects can be used as hash keys and that configurations with the same
35
+ # queue name will have the same hash value regardless of their options.
38
36
  #
39
37
  # @return [Integer] Hash value based on the queue name
40
38
  def hash
@@ -43,9 +41,8 @@ module Shoryuken
43
41
 
44
42
  # Compares this configuration with another object for equality.
45
43
  #
46
- # Two QueueConfiguration objects are equal if they have the same name
47
- # and options. For convenience, a configuration with empty options can
48
- # also be compared directly with a string queue name.
44
+ # Two QueueConfiguration objects are equal if they have the same name and options. For convenience, a configuration
45
+ # with empty options can also be compared directly with a string queue name.
49
46
  #
50
47
  # @param other [Object] The object to compare with
51
48
  # @return [Boolean] true if the objects are considered equal
@@ -78,9 +75,8 @@ module Shoryuken
78
75
 
79
76
  # Returns a string representation of the queue configuration.
80
77
  #
81
- # For configurations with empty options, returns just the queue name.
82
- # For configurations with options, returns a detailed representation
83
- # showing both the name and the options hash.
78
+ # For configurations with empty options, returns just the queue name. For configurations with options, returns a
79
+ # detailed representation showing both the name and the options hash.
84
80
  #
85
81
  # @return [String] String representation of the configuration
86
82
  #
@@ -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,20 @@ 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: message_processed marks a queue ready by setting
94
+ # its time to the epoch, and that entry may sit behind an earlier-paused queue that is still paused - checking only the
95
+ # head would leave the ready queue stuck.
83
96
  #
84
97
  # @return [void]
85
98
  def unpause_queues
86
- return if @paused_queues.empty?
87
- return if Time.now < @paused_queues.first[0]
99
+ index = @paused_queues.index { |time, _name| time <= Time.now }
100
+ return if index.nil?
88
101
 
89
- pause = @paused_queues.shift
90
- @queues << pause[1]
91
- logger.debug "Unpaused #{pause[1]}"
102
+ paused = @paused_queues.delete_at(index)
103
+ @queues << paused[1]
104
+ logger.debug "Unpaused #{paused[1]}"
92
105
  end
93
106
 
94
107
  # Returns the current weight of a queue in the active rotation