shoryuken 7.0.2 → 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 (53) 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/CHANGELOG.md +107 -0
  7. data/bin/cli/sqs.rb +7 -0
  8. data/bin/integrations +52 -34
  9. data/lib/active_job/queue_adapters/shoryuken_adapter.rb +3 -1
  10. data/lib/shoryuken/active_job/current_attributes.rb +35 -7
  11. data/lib/shoryuken/fetcher.rb +7 -1
  12. data/lib/shoryuken/helpers/timer_task.rb +19 -2
  13. data/lib/shoryuken/launcher.rb +21 -5
  14. data/lib/shoryuken/manager.rb +38 -5
  15. data/lib/shoryuken/middleware/server/auto_extend_visibility.rb +32 -2
  16. data/lib/shoryuken/middleware/server/exponential_backoff_retry.rb +8 -3
  17. data/lib/shoryuken/middleware/server/non_retryable_exception.rb +17 -8
  18. data/lib/shoryuken/options.rb +12 -1
  19. data/lib/shoryuken/polling/strict_priority.rb +26 -14
  20. data/lib/shoryuken/polling/weighted_round_robin.rb +41 -27
  21. data/lib/shoryuken/queue.rb +8 -1
  22. data/lib/shoryuken/util.rb +4 -1
  23. data/lib/shoryuken/version.rb +1 -1
  24. data/lib/shoryuken/worker.rb +5 -1
  25. data/lib/shoryuken.rb +2 -0
  26. data/renovate.json +16 -2
  27. data/spec/integration/active_job/current_attributes/cross_job_reset_spec.rb +47 -0
  28. data/spec/integration/active_job/current_attributes/incremental_persist_spec.rb +76 -0
  29. data/spec/integration/active_job/fifo_dedup_opt_out/fifo_dedup_opt_out_spec.rb +67 -0
  30. data/spec/integration/auto_extend_visibility/short_visibility_timeout_spec.rb +52 -0
  31. data/spec/integration/concurrent_processing/processor_accounting_spec.rb +94 -0
  32. data/spec/integration/fifo_ordering/fifo_max_messages_cap_spec.rb +96 -0
  33. data/spec/integration/launcher/double_graceful_stop_spec.rb +71 -0
  34. data/spec/integration/launcher/embedded_dispatch_error_spec.rb +85 -0
  35. data/spec/integration/launcher/global_executor_preserved_spec.rb +76 -0
  36. data/spec/integration/launcher/graceful_stop_timeout_spec.rb +74 -0
  37. data/spec/integration/message_operations/partial_batch_delete_spec.rb +67 -0
  38. data/spec/integration/non_retryable_exception/non_retryable_exception_spec.rb +1 -1
  39. data/spec/integration/non_retryable_exception/with_retry_intervals_spec.rb +115 -0
  40. data/spec/integrations_helper.rb +10 -9
  41. data/spec/lib/shoryuken/fetcher_spec.rb +13 -0
  42. data/spec/lib/shoryuken/helpers/timer_task_spec.rb +24 -0
  43. data/spec/lib/shoryuken/launcher_spec.rb +38 -0
  44. data/spec/lib/shoryuken/manager_spec.rb +99 -0
  45. data/spec/lib/shoryuken/middleware/server/auto_extend_visibility_spec.rb +35 -0
  46. data/spec/lib/shoryuken/middleware/server/exponential_backoff_retry_spec.rb +56 -0
  47. data/spec/lib/shoryuken/polling/strict_priority_spec.rb +25 -0
  48. data/spec/lib/shoryuken/polling/weighted_round_robin_spec.rb +50 -0
  49. data/spec/lib/shoryuken/queue_spec.rb +37 -0
  50. data/spec/lib/shoryuken/util_spec.rb +26 -0
  51. data/spec/shared_examples_for_active_job.rb +18 -0
  52. data/spec/spec_helper.rb +26 -7
  53. metadata +26 -2
@@ -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 }
@@ -302,6 +304,15 @@ module Shoryuken
302
304
  @active_job_queue_name_prefixing
303
305
  end
304
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
+
305
316
  private
306
317
 
307
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.2'
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
@@ -56,7 +56,21 @@
56
56
  "ruby/setup-ruby",
57
57
  "ruby"
58
58
  ],
59
- "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"
60
68
  }
61
- ]
69
+ ],
70
+ "lockFileMaintenance": {
71
+ "enabled": true,
72
+ "schedule": [
73
+ "before 4am on the first day of the month"
74
+ ]
75
+ }
62
76
  }
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A message without a CurrentAttributes key must not inherit a previous job's
4
+ # context. Every registered class is reset after each job - even keyless ones -
5
+ # so values set during one job don't leak into the next on a reused worker
6
+ # thread.
7
+
8
+ setup_sqs
9
+ setup_active_job
10
+
11
+ require 'active_support/current_attributes'
12
+ require 'shoryuken/active_job/current_attributes'
13
+
14
+ queue_name = DT.queue
15
+ create_test_queue(queue_name)
16
+
17
+ class LeakCurrent < ActiveSupport::CurrentAttributes
18
+ attribute :user_id
19
+ end
20
+
21
+ Shoryuken::ActiveJob::CurrentAttributes.persist(LeakCurrent)
22
+
23
+ class CrossJobResetTestJob < ActiveJob::Base
24
+ def perform
25
+ # Record what the worker thread sees at the start of the job, then dirty
26
+ # Current. Without a reset after a keyless job, the second execution on the
27
+ # same thread would observe 'leaked' instead of nil.
28
+ DT[:observed] << LeakCurrent.user_id
29
+ LeakCurrent.user_id = 'leaked'
30
+ end
31
+ end
32
+
33
+ CrossJobResetTestJob.queue_as(queue_name)
34
+
35
+ # Concurrency 1 so the two jobs run sequentially on the same worker thread.
36
+ Shoryuken.add_group('default', 1)
37
+ Shoryuken.add_queue(queue_name, 1, 'default')
38
+ Shoryuken.register_worker(queue_name, Shoryuken::ActiveJob::JobWrapper)
39
+
40
+ # Enqueued with an empty context, so both messages carry no cattr key.
41
+ CrossJobResetTestJob.perform_later
42
+ CrossJobResetTestJob.perform_later
43
+
44
+ poll_queues_until(timeout: 30) { DT[:observed].size >= 2 }
45
+
46
+ assert_equal([nil, nil], DT[:observed].first(2),
47
+ "CurrentAttributes leaked across jobs: #{DT[:observed].inspect}")
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ # CurrentAttributes registered one class per persist call are all preserved.
4
+ #
5
+ # persist derived each storage key from the per-call index, so calling it once
6
+ # per class (as an app might across several initializers) made the third call
7
+ # reuse "cattr_0" and silently overwrite the second class - its attributes were
8
+ # then never serialized or restored.
9
+
10
+ setup_sqs
11
+ setup_active_job
12
+
13
+ require 'active_support/current_attributes'
14
+ require 'shoryuken/active_job/current_attributes'
15
+
16
+ queue_name = DT.queue
17
+ create_test_queue(queue_name)
18
+
19
+ class CurrentA < ActiveSupport::CurrentAttributes
20
+ attribute :a_val
21
+ end
22
+
23
+ class CurrentB < ActiveSupport::CurrentAttributes
24
+ attribute :b_val
25
+ end
26
+
27
+ class CurrentC < ActiveSupport::CurrentAttributes
28
+ attribute :c_val
29
+ end
30
+
31
+ # Registered incrementally, one class per call.
32
+ Shoryuken::ActiveJob::CurrentAttributes.persist(CurrentA)
33
+ Shoryuken::ActiveJob::CurrentAttributes.persist(CurrentB)
34
+ Shoryuken::ActiveJob::CurrentAttributes.persist(CurrentC)
35
+
36
+ # Every class must be registered, each under a distinct storage key.
37
+ registered = Shoryuken::ActiveJob::CurrentAttributes.cattrs
38
+ assert_equal(
39
+ %w[CurrentA CurrentB CurrentC],
40
+ registered.values.sort,
41
+ 'every persisted CurrentAttributes class must be registered (none silently dropped)'
42
+ )
43
+ assert_equal(3, registered.keys.uniq.size, 'each class must use a distinct storage key')
44
+
45
+ class IncrementalCurrentJob < ActiveJob::Base
46
+ def perform
47
+ DT[:executions] << {
48
+ a_val: CurrentA.a_val,
49
+ b_val: CurrentB.b_val,
50
+ c_val: CurrentC.c_val
51
+ }
52
+ end
53
+ end
54
+
55
+ IncrementalCurrentJob.queue_as(queue_name)
56
+
57
+ Shoryuken.add_group('default', 1)
58
+ Shoryuken.add_queue(queue_name, 1, 'default')
59
+ Shoryuken.register_worker(queue_name, Shoryuken::ActiveJob::JobWrapper)
60
+
61
+ CurrentA.a_val = 'a-value'
62
+ CurrentB.b_val = 'b-value'
63
+ CurrentC.c_val = 'c-value'
64
+
65
+ IncrementalCurrentJob.perform_later
66
+
67
+ CurrentA.reset
68
+ CurrentB.reset
69
+ CurrentC.reset
70
+
71
+ poll_queues_until(timeout: 30) { DT[:executions].size >= 1 }
72
+
73
+ result = DT[:executions].first
74
+ assert_equal('a-value', result[:a_val])
75
+ assert_equal('b-value', result[:b_val], 'the second incrementally-persisted class must not be dropped')
76
+ assert_equal('c-value', result[:c_val])
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ # This spec tests opting out of the automatic FIFO message_deduplication_id that
6
+ # the ActiveJob adapter generates.
7
+ #
8
+ # For FIFO queues the adapter derives a content-based message_deduplication_id
9
+ # from the serialized job minus job_id/enqueued_at (see issues #457 / #750). That
10
+ # means two *distinct* enqueues of the same job class + args within SQS's 5-minute
11
+ # window collapse into one - the second is silently dropped. That is intentional,
12
+ # but it is a "skipped message" trap for users who legitimately enqueue the same
13
+ # work twice.
14
+ #
15
+ # Expected behavior: setting Shoryuken.active_job_fifo_message_deduplication = false
16
+ # stops the adapter from generating the id, so those enqueues are no longer
17
+ # silently deduplicated. The default (true) preserves the existing behavior.
18
+ #
19
+ # Regression: there was no way to disable the auto-generated deduplication id.
20
+
21
+ setup_active_job
22
+
23
+ class FifoOptOutJob < ActiveJob::Base
24
+ queue_as :fifo_opt_out
25
+
26
+ def perform(_arg); end
27
+ end
28
+
29
+ fifo_queue_mock = Object.new
30
+ fifo_queue_mock.define_singleton_method(:fifo?) { true }
31
+ fifo_queue_mock.define_singleton_method(:name) { 'fifo_opt_out.fifo' }
32
+
33
+ captured = nil
34
+ fifo_queue_mock.define_singleton_method(:send_message) { |params| captured = params }
35
+
36
+ Shoryuken::Client.define_singleton_method(:queues) { |_queue_name = nil| fifo_queue_mock }
37
+ Shoryuken.define_singleton_method(:register_worker) { |*| nil }
38
+
39
+ # Default behavior: the adapter sets a content-based dedup id (excluding the
40
+ # per-enqueue job_id/enqueued_at), so two identical jobs would collapse to one.
41
+ FifoOptOutJob.perform_later('same-args')
42
+
43
+ assert(
44
+ captured.key?(:message_deduplication_id),
45
+ 'by default a FIFO ActiveJob send gets an auto-generated message_deduplication_id'
46
+ )
47
+
48
+ body = captured[:message_body]
49
+ expected = Digest::SHA256.hexdigest(JSON.dump(body.except('job_id', 'enqueued_at')))
50
+ assert_equal(expected, captured[:message_deduplication_id])
51
+
52
+ # Opt-out: with deduplication disabled the adapter no longer sets a dedup id, so
53
+ # distinct enqueues of identical jobs are not silently dropped.
54
+ Shoryuken.active_job_fifo_message_deduplication = false
55
+ captured = nil
56
+ FifoOptOutJob.perform_later('same-args')
57
+
58
+ refute(
59
+ captured.key?(:message_deduplication_id),
60
+ 'with deduplication disabled the adapter must not set message_deduplication_id'
61
+ )
62
+
63
+ # An explicit deduplication id must still be honored even when auto-generation is off.
64
+ captured = nil
65
+ FifoOptOutJob.set(message_deduplication_id: 'explicit-id').perform_later('same-args')
66
+
67
+ assert_equal('explicit-id', captured[:message_deduplication_id])