shoryuken 7.0.0.alpha2 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/push.yml +2 -2
  3. data/.github/workflows/specs.yml +38 -43
  4. data/.github/workflows/verify-action-pins.yml +1 -1
  5. data/.gitignore +3 -0
  6. data/.rspec +1 -0
  7. data/.ruby-version +1 -1
  8. data/.yard-lint.yml +279 -0
  9. data/CHANGELOG.md +69 -1
  10. data/Gemfile +1 -1
  11. data/README.md +2 -7
  12. data/Rakefile +4 -10
  13. data/bin/clean_localstack +52 -0
  14. data/bin/cli/base.rb +21 -0
  15. data/bin/cli/sqs.rb +61 -2
  16. data/bin/integrations +275 -0
  17. data/bin/scenario +154 -0
  18. data/bin/shoryuken +1 -1
  19. data/lib/{shoryuken/extensions/active_job_extensions.rb → active_job/extensions.rb} +15 -4
  20. data/lib/active_job/queue_adapters/shoryuken_adapter.rb +208 -0
  21. data/lib/active_job/queue_adapters/shoryuken_concurrent_send_adapter.rb +78 -0
  22. data/lib/shoryuken/active_job/current_attributes.rb +139 -0
  23. data/lib/shoryuken/active_job/job_wrapper.rb +28 -0
  24. data/lib/shoryuken/body_parser.rb +8 -0
  25. data/lib/shoryuken/client.rb +14 -0
  26. data/lib/shoryuken/default_exception_handler.rb +9 -0
  27. data/lib/shoryuken/default_worker_registry.rb +29 -1
  28. data/lib/shoryuken/environment_loader.rb +78 -8
  29. data/lib/shoryuken/errors.rb +33 -0
  30. data/lib/shoryuken/fetcher.rb +37 -1
  31. data/lib/shoryuken/helpers/atomic_boolean.rb +19 -5
  32. data/lib/shoryuken/helpers/timer_task.rb +80 -0
  33. data/lib/shoryuken/launcher.rb +53 -0
  34. data/lib/shoryuken/logging/base.rb +26 -0
  35. data/lib/shoryuken/logging/pretty.rb +25 -0
  36. data/lib/shoryuken/logging/without_timestamp.rb +25 -0
  37. data/lib/shoryuken/logging.rb +39 -25
  38. data/lib/shoryuken/manager.rb +70 -1
  39. data/lib/shoryuken/message.rb +114 -1
  40. data/lib/shoryuken/middleware/chain.rb +139 -43
  41. data/lib/shoryuken/middleware/entry.rb +30 -0
  42. data/lib/shoryuken/middleware/server/active_record.rb +8 -0
  43. data/lib/shoryuken/middleware/server/auto_delete.rb +10 -0
  44. data/lib/shoryuken/middleware/server/auto_extend_visibility.rb +27 -1
  45. data/lib/shoryuken/middleware/server/exponential_backoff_retry.rb +29 -0
  46. data/lib/shoryuken/middleware/server/timing.rb +11 -0
  47. data/lib/shoryuken/options.rb +129 -6
  48. data/lib/shoryuken/polling/base_strategy.rb +1 -0
  49. data/lib/shoryuken/polling/strict_priority.rb +39 -0
  50. data/lib/shoryuken/polling/weighted_round_robin.rb +42 -0
  51. data/lib/shoryuken/processor.rb +32 -1
  52. data/lib/shoryuken/queue.rb +93 -4
  53. data/lib/shoryuken/runner.rb +45 -4
  54. data/lib/shoryuken/util.rb +26 -1
  55. data/lib/shoryuken/version.rb +2 -1
  56. data/lib/shoryuken/worker/default_executor.rb +21 -1
  57. data/lib/shoryuken/worker/inline_executor.rb +24 -0
  58. data/lib/shoryuken/worker.rb +193 -0
  59. data/lib/shoryuken/worker_registry.rb +33 -0
  60. data/lib/shoryuken.rb +18 -6
  61. data/renovate.json +29 -2
  62. data/shoryuken.gemspec +2 -1
  63. data/spec/integration/.rspec +1 -0
  64. data/spec/integration/active_job/adapter_configuration/configuration_spec.rb +26 -0
  65. data/spec/integration/active_job/bulk_enqueue/bulk_enqueue_spec.rb +53 -0
  66. data/spec/integration/active_job/current_attributes/bulk_enqueue_spec.rb +50 -0
  67. data/spec/integration/active_job/current_attributes/complex_types_spec.rb +55 -0
  68. data/spec/integration/active_job/current_attributes/empty_context_spec.rb +41 -0
  69. data/spec/integration/active_job/current_attributes/full_context_spec.rb +63 -0
  70. data/spec/integration/active_job/current_attributes/partial_context_spec.rb +57 -0
  71. data/spec/integration/active_job/custom_attributes/number_attributes_spec.rb +37 -0
  72. data/spec/integration/active_job/custom_attributes/string_attributes_spec.rb +39 -0
  73. data/spec/integration/active_job/error_handling/job_wrapper_spec.rb +53 -0
  74. data/spec/integration/active_job/fifo_and_attributes/deduplication_spec.rb +86 -0
  75. data/spec/integration/active_job/retry/discard_on_spec.rb +43 -0
  76. data/spec/integration/active_job/retry/retry_on_spec.rb +36 -0
  77. data/spec/integration/active_job/roundtrip/roundtrip_spec.rb +52 -0
  78. data/spec/integration/active_job/scheduled/scheduled_spec.rb +76 -0
  79. data/spec/integration/active_record_middleware/active_record_middleware_spec.rb +84 -0
  80. data/spec/integration/auto_delete/auto_delete_spec.rb +53 -0
  81. data/spec/integration/auto_extend_visibility/auto_extend_visibility_spec.rb +57 -0
  82. data/spec/integration/aws_config/aws_config_spec.rb +59 -0
  83. data/spec/integration/batch_processing/batch_processing_spec.rb +37 -0
  84. data/spec/integration/body_parser/json_parser_spec.rb +45 -0
  85. data/spec/integration/body_parser/proc_parser_spec.rb +54 -0
  86. data/spec/integration/body_parser/text_parser_spec.rb +43 -0
  87. data/spec/integration/concurrent_processing/concurrent_processing_spec.rb +45 -0
  88. data/spec/integration/dead_letter_queue/dead_letter_queue_spec.rb +91 -0
  89. data/spec/integration/exception_handlers/exception_handlers_spec.rb +69 -0
  90. data/spec/integration/exponential_backoff/exponential_backoff_spec.rb +67 -0
  91. data/spec/integration/fifo_ordering/fifo_ordering_spec.rb +44 -0
  92. data/spec/integration/large_payloads/large_payloads_spec.rb +30 -0
  93. data/spec/integration/launcher/launcher_spec.rb +40 -0
  94. data/spec/integration/message_attributes/message_attributes_spec.rb +54 -0
  95. data/spec/integration/message_operations/message_operations_spec.rb +59 -0
  96. data/spec/integration/middleware_chain/empty_chain_spec.rb +11 -0
  97. data/spec/integration/middleware_chain/execution_order_spec.rb +33 -0
  98. data/spec/integration/middleware_chain/removal_spec.rb +31 -0
  99. data/spec/integration/middleware_chain/short_circuit_spec.rb +40 -0
  100. data/spec/integration/polling_strategies/polling_strategies_spec.rb +46 -0
  101. data/spec/integration/queue_operations/queue_operations_spec.rb +84 -0
  102. data/spec/integration/rails/rails_72/Gemfile +6 -0
  103. data/spec/integration/rails/rails_72/activejob_adapter_spec.rb +98 -0
  104. data/spec/integration/rails/rails_80/Gemfile +6 -0
  105. data/spec/integration/rails/rails_80/activejob_adapter_spec.rb +98 -0
  106. data/spec/integration/rails/rails_80/continuation_spec.rb +79 -0
  107. data/spec/integration/rails/rails_81/Gemfile +6 -0
  108. data/spec/integration/rails/rails_81/activejob_adapter_spec.rb +98 -0
  109. data/spec/integration/rails/rails_81/continuation_spec.rb +79 -0
  110. data/spec/integration/retry_behavior/retry_behavior_spec.rb +45 -0
  111. data/spec/integration/spec_helper.rb +7 -0
  112. data/spec/integration/strict_priority_polling/strict_priority_polling_spec.rb +58 -0
  113. data/spec/integration/visibility_timeout/visibility_timeout_spec.rb +37 -0
  114. data/spec/integration/worker_enqueueing/worker_enqueueing_spec.rb +60 -0
  115. data/spec/integration/worker_groups/worker_groups_spec.rb +79 -0
  116. data/spec/integration/worker_lifecycle/worker_lifecycle_spec.rb +33 -0
  117. data/spec/integrations_helper.rb +243 -0
  118. data/spec/lib/active_job/extensions_spec.rb +149 -0
  119. data/spec/lib/active_job/queue_adapters/shoryuken_adapter_spec.rb +29 -0
  120. data/spec/{shoryuken/extensions/active_job_concurrent_send_adapter_spec.rb → lib/active_job/queue_adapters/shoryuken_concurrent_send_adapter_spec.rb} +3 -3
  121. data/spec/{shoryuken/extensions/active_job_wrapper_spec.rb → lib/shoryuken/active_job/job_wrapper_spec.rb} +4 -4
  122. data/spec/{shoryuken → lib/shoryuken}/environment_loader_spec.rb +1 -1
  123. data/spec/{shoryuken → lib/shoryuken}/helpers/hash_utils_spec.rb +14 -14
  124. data/spec/{shoryuken → lib/shoryuken}/helpers/string_utils_spec.rb +3 -3
  125. data/spec/lib/shoryuken/helpers/timer_task_spec.rb +298 -0
  126. data/spec/{shoryuken → lib/shoryuken}/helpers_integration_spec.rb +9 -9
  127. data/spec/{shoryuken → lib/shoryuken}/launcher_spec.rb +22 -0
  128. data/spec/lib/shoryuken/logging_spec.rb +242 -0
  129. data/spec/lib/shoryuken/message_spec.rb +109 -0
  130. data/spec/lib/shoryuken/middleware/entry_spec.rb +68 -0
  131. data/spec/lib/shoryuken/middleware/server/active_record_spec.rb +133 -0
  132. data/spec/{shoryuken → lib/shoryuken}/middleware/server/auto_extend_visibility_spec.rb +50 -0
  133. data/spec/{shoryuken → lib/shoryuken}/options_spec.rb +2 -2
  134. data/spec/{shoryuken → lib/shoryuken}/util_spec.rb +1 -1
  135. data/spec/lib/shoryuken/version_spec.rb +17 -0
  136. data/spec/lib/shoryuken/worker_registry_spec.rb +63 -0
  137. data/spec/shared_examples_for_active_job.rb +29 -9
  138. data/spec/spec_helper.rb +34 -3
  139. metadata +230 -91
  140. data/.devcontainer/Dockerfile +0 -17
  141. data/.devcontainer/base.Dockerfile +0 -43
  142. data/.devcontainer/devcontainer.json +0 -35
  143. data/Appraisals +0 -23
  144. data/gemfiles/.gitignore +0 -1
  145. data/gemfiles/rails_7_0.gemfile +0 -19
  146. data/gemfiles/rails_7_1.gemfile +0 -19
  147. data/gemfiles/rails_7_2.gemfile +0 -19
  148. data/gemfiles/rails_8_0.gemfile +0 -19
  149. data/lib/shoryuken/extensions/active_job_adapter.rb +0 -110
  150. data/lib/shoryuken/extensions/active_job_concurrent_send_adapter.rb +0 -50
  151. data/spec/integration/launcher_spec.rb +0 -127
  152. data/spec/shoryuken/extensions/active_job_adapter_spec.rb +0 -8
  153. data/spec/shoryuken/extensions/active_job_base_spec.rb +0 -85
  154. /data/spec/{shoryuken → lib/shoryuken}/body_parser_spec.rb +0 -0
  155. /data/spec/{shoryuken → lib/shoryuken}/client_spec.rb +0 -0
  156. /data/spec/{shoryuken → lib/shoryuken}/default_exception_handler_spec.rb +0 -0
  157. /data/spec/{shoryuken → lib/shoryuken}/default_worker_registry_spec.rb +0 -0
  158. /data/spec/{shoryuken → lib/shoryuken}/fetcher_spec.rb +0 -0
  159. /data/spec/{shoryuken → lib/shoryuken}/helpers/atomic_boolean_spec.rb +0 -0
  160. /data/spec/{shoryuken → lib/shoryuken}/helpers/atomic_counter_spec.rb +0 -0
  161. /data/spec/{shoryuken → lib/shoryuken}/helpers/atomic_hash_spec.rb +0 -0
  162. /data/spec/{shoryuken → lib/shoryuken}/inline_message_spec.rb +0 -0
  163. /data/spec/{shoryuken → lib/shoryuken}/manager_spec.rb +0 -0
  164. /data/spec/{shoryuken → lib/shoryuken}/middleware/chain_spec.rb +0 -0
  165. /data/spec/{shoryuken → lib/shoryuken}/middleware/server/auto_delete_spec.rb +0 -0
  166. /data/spec/{shoryuken → lib/shoryuken}/middleware/server/exponential_backoff_retry_spec.rb +0 -0
  167. /data/spec/{shoryuken → lib/shoryuken}/middleware/server/timing_spec.rb +0 -0
  168. /data/spec/{shoryuken → lib/shoryuken}/polling/base_strategy_spec.rb +0 -0
  169. /data/spec/{shoryuken → lib/shoryuken}/polling/queue_configuration_spec.rb +0 -0
  170. /data/spec/{shoryuken → lib/shoryuken}/polling/strict_priority_spec.rb +0 -0
  171. /data/spec/{shoryuken → lib/shoryuken}/polling/weighted_round_robin_spec.rb +0 -0
  172. /data/spec/{shoryuken → lib/shoryuken}/processor_spec.rb +0 -0
  173. /data/spec/{shoryuken → lib/shoryuken}/queue_spec.rb +0 -0
  174. /data/spec/{shoryuken → lib/shoryuken}/runner_spec.rb +0 -0
  175. /data/spec/{shoryuken → lib/shoryuken}/worker/default_executor_spec.rb +0 -0
  176. /data/spec/{shoryuken → lib/shoryuken}/worker/inline_executor_spec.rb +0 -0
  177. /data/spec/{shoryuken → lib/shoryuken}/worker_spec.rb +0 -0
  178. /data/spec/{shoryuken_spec.rb → lib/shoryuken_spec.rb} +0 -0
data/bin/cli/sqs.rb CHANGED
@@ -1,19 +1,28 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'date'
2
4
 
3
5
  # rubocop:disable Metrics/BlockLength
4
6
  module Shoryuken
5
7
  module CLI
8
+ # SQS command line interface for queue management operations.
9
+ # Provides commands for listing, creating, deleting, moving, and dumping queue messages.
6
10
  class SQS < Base
7
- # See https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/quotas-messages.html
11
+ # Maximum batch size in bytes for SQS batch operations
12
+ # @see https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/quotas-messages.html
8
13
  MAX_BATCH_SIZE = 1024 * 1024
9
14
 
10
15
  namespace :sqs
11
16
  class_option :endpoint, aliases: '-e', type: :string, default: ENV['SHORYUKEN_SQS_ENDPOINT'], desc: 'Endpoint URL'
12
17
 
13
18
  no_commands do
19
+ # Normalizes a dump message for requeuing
20
+ #
21
+ # @param message [Hash] the dumped message hash
22
+ # @return [Hash] the normalized message
14
23
  def normalize_dump_message(message)
15
24
  # symbolize_keys is needed for keeping it compatible with `requeue`
16
- attributes = message[:attributes].symbolize_keys
25
+ attributes = message[:attributes].transform_keys(&:to_sym)
17
26
 
18
27
  # See https://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/API_MessageAttributeValue.html
19
28
  # The `string_list_values` and `binary_list_values` are not implemented. Reserved for future use.
@@ -30,6 +39,9 @@ module Shoryuken
30
39
  }
31
40
  end
32
41
 
42
+ # Returns the SQS client options based on CLI options
43
+ #
44
+ # @return [Hash] the client options hash
33
45
  def client_options
34
46
  endpoint = options[:endpoint]
35
47
  {}.tap do |hash|
@@ -37,16 +49,28 @@ module Shoryuken
37
49
  end
38
50
  end
39
51
 
52
+ # Returns the SQS client instance
53
+ #
54
+ # @return [Aws::SQS::Client] the SQS client
40
55
  def sqs
41
56
  @_sqs ||= Aws::SQS::Client.new(client_options)
42
57
  end
43
58
 
59
+ # Finds the URL for a queue by name
60
+ #
61
+ # @param queue_name [String] the queue name
62
+ # @return [String] the queue URL
44
63
  def find_queue_url(queue_name)
45
64
  sqs.get_queue_url(queue_name: queue_name).queue_url
46
65
  rescue Aws::SQS::Errors::NonExistentQueue
47
66
  fail_task "The specified queue #{queue_name} does not exist"
48
67
  end
49
68
 
69
+ # Batch deletes messages from a queue
70
+ #
71
+ # @param url [String] the queue URL
72
+ # @param messages [Array<Aws::SQS::Types::Message>] the messages to delete
73
+ # @return [void]
50
74
  def batch_delete(url, messages)
51
75
  messages.to_a.flatten.each_slice(10) do |batch|
52
76
  sqs.delete_message_batch(
@@ -61,11 +85,23 @@ module Shoryuken
61
85
  end
62
86
  end
63
87
 
88
+ # Batch sends messages to a queue
89
+ #
90
+ # @param url [String] the queue URL
91
+ # @param messages [Array<Hash>] the messages to send
92
+ # @param max_batch_size [Integer] maximum messages per batch
93
+ # @return [void]
64
94
  def batch_send(url, messages, max_batch_size = 10)
65
95
  messages = messages.to_a.flatten.map(&method(:normalize_dump_message))
66
96
  batch_send_normalized_messages url, messages, max_batch_size
67
97
  end
68
98
 
99
+ # Batch sends normalized messages to a queue
100
+ #
101
+ # @param url [String] the queue URL
102
+ # @param messages [Array<Hash>] the normalized messages
103
+ # @param max_batch_size [Integer] maximum messages per batch
104
+ # @return [void]
69
105
  def batch_send_normalized_messages(url, messages, max_batch_size)
70
106
  # Repeatedly take the longest prefix of messages such that
71
107
  # 1. The number of messages is less than or equal to max_batch_size
@@ -90,10 +126,18 @@ module Shoryuken
90
126
  end
91
127
  end
92
128
 
129
+ # Calculates the total payload size of a batch
130
+ #
131
+ # @param messages [Array<Hash>] the messages
132
+ # @return [Integer] total size in bytes
93
133
  def batch_payload_size(messages)
94
134
  messages.sum(&method(:message_size))
95
135
  end
96
136
 
137
+ # Calculates the size of a single message
138
+ #
139
+ # @param message [Hash] the message
140
+ # @return [Integer] size in bytes
97
141
  def message_size(message)
98
142
  attribute_size = (message[:message_attributes] || []).sum do |name, value|
99
143
  name_size = name.to_s.bytesize
@@ -111,6 +155,12 @@ module Shoryuken
111
155
  attribute_size + body_size
112
156
  end
113
157
 
158
+ # Receives all messages from a queue up to a limit
159
+ #
160
+ # @param url [String] the queue URL
161
+ # @param limit [Integer, Float] maximum messages to receive
162
+ # @yield [Aws::SQS::Types::Message] yields each received message
163
+ # @return [Integer] the number of messages received
114
164
  def find_all(url, limit, &block)
115
165
  count = 0
116
166
  batch_size = limit > 10 ? 10 : limit
@@ -137,6 +187,10 @@ module Shoryuken
137
187
  count
138
188
  end
139
189
 
190
+ # Lists queues and prints their attributes as a table
191
+ #
192
+ # @param urls [Array<String>] the queue URLs
193
+ # @return [void]
140
194
  def list_and_print_queues(urls)
141
195
  attrs = %w[QueueArn ApproximateNumberOfMessages ApproximateNumberOfMessagesNotVisible LastModifiedTimestamp]
142
196
 
@@ -154,6 +208,11 @@ module Shoryuken
154
208
  print_table(entries)
155
209
  end
156
210
 
211
+ # Generates the dump file path for a queue
212
+ #
213
+ # @param path [String] the directory path
214
+ # @param queue_name [String] the queue name
215
+ # @return [String] the full file path
157
216
  def dump_file(path, queue_name)
158
217
  File.join(path, "#{queue_name}-#{Date.today}.jsonl")
159
218
  end
data/bin/integrations ADDED
@@ -0,0 +1,275 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Shoryuken integration test runner
5
+ #
6
+ # Usage:
7
+ # bin/integrations # Run all integration tests
8
+ # bin/integrations fifo # Run tests with 'fifo' in path
9
+ # bin/integrations rails/rails_72 # Run Rails 7.2 tests
10
+ # bin/integrations batch retry # Run tests matching 'batch' OR 'retry'
11
+ # bin/integrations -v fifo # Run with verbose output
12
+
13
+ require 'bundler'
14
+ require 'fileutils'
15
+ require 'timeout'
16
+
17
+ TIMEOUT = 300 # 5 minutes per scenario
18
+ SPEC_DIR = File.expand_path('../spec/integration', __dir__)
19
+ ROOT_DIR = File.expand_path('..', __dir__)
20
+
21
+ class IntegrationRunner
22
+ def initialize(args)
23
+ @verbose = args.delete('-v') || args.delete('--verbose')
24
+ @filters = args.reject { |a| a.start_with?('-') }
25
+ end
26
+
27
+ def run
28
+ specs = find_specs
29
+ specs = filter_specs(specs) if @filters.any?
30
+
31
+ if specs.empty?
32
+ puts 'No specs found matching filters'
33
+ exit 1
34
+ end
35
+
36
+ puts "Running #{specs.size} integration specs..."
37
+ puts
38
+
39
+ results = run_specs(specs)
40
+ report_results(results)
41
+ end
42
+
43
+ private
44
+
45
+ def find_specs
46
+ Dir.glob(File.join(SPEC_DIR, '**/*_spec.rb')).reject do |path|
47
+ # Exclude vendor and .bundle directories
48
+ path.include?('/vendor/') || path.include?('/.bundle/')
49
+ end.map do |path|
50
+ relative_path = path.sub("#{SPEC_DIR}/", '')
51
+ dir = File.dirname(path)
52
+ gemfile = File.exist?(File.join(dir, 'Gemfile')) ? File.join(dir, 'Gemfile') : File.join(ROOT_DIR, 'Gemfile')
53
+
54
+ {
55
+ name: relative_path.sub('_spec.rb', '').gsub('/', ' / '),
56
+ path: path,
57
+ relative_path: relative_path,
58
+ directory: dir,
59
+ gemfile: gemfile
60
+ }
61
+ end.sort_by { |s| s[:relative_path] }
62
+ end
63
+
64
+ def filter_specs(specs)
65
+ specs.select do |spec|
66
+ @filters.any? { |filter| spec[:relative_path].include?(filter) }
67
+ end
68
+ end
69
+
70
+ def run_specs(specs)
71
+ results = []
72
+
73
+ specs.each do |spec|
74
+ result = run_spec(spec)
75
+ results << result
76
+
77
+ if result[:skipped]
78
+ print 'S'
79
+ elsif result[:success]
80
+ print '.'
81
+ else
82
+ print 'F'
83
+ end
84
+ $stdout.flush
85
+ end
86
+
87
+ puts
88
+ results
89
+ end
90
+
91
+ def run_spec(spec)
92
+ # Start with a clean bundler environment to prevent pollution between tests
93
+ env = {
94
+ 'BUNDLE_GEMFILE' => spec[:gemfile],
95
+ 'RAILS_ENV' => 'test',
96
+ 'RUBYOPT' => nil # Clear any -rbundler/setup from CI or previous tests
97
+ }
98
+
99
+ # Install dependencies if using a local Gemfile
100
+ uses_local_gemfile = spec[:gemfile] != File.join(ROOT_DIR, 'Gemfile')
101
+ if uses_local_gemfile
102
+ install_result = install_bundle(spec, env)
103
+ unless install_result[:success]
104
+ # Skip test if bundle install fails (e.g., gems not available in CI)
105
+ return {
106
+ spec: spec,
107
+ success: true,
108
+ skipped: true,
109
+ skip_reason: 'Bundle install failed (dependencies not available)',
110
+ output: install_result[:output]
111
+ }
112
+ end
113
+
114
+ # Use isolated bundle config to match install_bundle
115
+ bundle_path = File.join(spec[:directory], 'vendor', 'bundle')
116
+ bundle_config = File.join(spec[:directory], '.bundle')
117
+ env['BUNDLE_PATH'] = bundle_path
118
+ env['BUNDLE_FROZEN'] = 'false'
119
+ env['BUNDLE_APP_CONFIG'] = bundle_config
120
+ end
121
+
122
+ # Run the spec
123
+ # For local gemfiles, use standalone bundle setup which doesn't need bundler at runtime
124
+ # This avoids issues with bundle exec inheriting the wrong config
125
+ cmd = if uses_local_gemfile
126
+ standalone_setup = File.join(bundle_path, 'bundler', 'setup.rb')
127
+ ['ruby', "-r#{standalone_setup}", File.join(ROOT_DIR, 'bin/scenario'), spec[:path]]
128
+ else
129
+ ['bundle', 'exec', 'ruby', File.join(ROOT_DIR, 'bin/scenario'), spec[:path]]
130
+ end
131
+
132
+ output = []
133
+ start_time = Time.now
134
+
135
+ begin
136
+ Timeout.timeout(TIMEOUT) do
137
+ # Use unbundled env to prevent pollution from previous test runs
138
+ # This is especially important after Rails integration tests that use
139
+ # bundle install --standalone with different gem versions
140
+ Bundler.with_unbundled_env do
141
+ IO.popen(env, cmd, chdir: spec[:directory], err: [:child, :out]) do |io|
142
+ io.each_line { |line| output << line }
143
+ end
144
+ end
145
+ end
146
+
147
+ {
148
+ spec: spec,
149
+ success: $?.success?,
150
+ exit_code: $?.exitstatus,
151
+ duration: Time.now - start_time,
152
+ output: output.join
153
+ }
154
+ rescue Timeout::Error
155
+ {
156
+ spec: spec,
157
+ success: false,
158
+ exit_code: -1,
159
+ error: 'Timeout',
160
+ duration: Time.now - start_time,
161
+ output: output.join
162
+ }
163
+ end
164
+ end
165
+
166
+ def install_bundle(spec, env)
167
+ return { success: true } if @bundle_installed&.include?(spec[:gemfile])
168
+
169
+ output = []
170
+
171
+ # Create isolated bundle environment to avoid CI cache interference
172
+ # Use a unique path per Gemfile to avoid conflicts
173
+ bundle_path = File.join(spec[:directory], 'vendor', 'bundle')
174
+ bundle_config = File.join(spec[:directory], '.bundle')
175
+
176
+ # Create local .bundle/config to override project-level config
177
+ FileUtils.mkdir_p(bundle_config)
178
+ File.write(File.join(bundle_config, 'config'), <<~CONFIG)
179
+ ---
180
+ BUNDLE_PATH: "#{bundle_path}"
181
+ BUNDLE_FROZEN: "false"
182
+ CONFIG
183
+
184
+ clean_env = env.merge(
185
+ 'BUNDLE_PATH' => bundle_path,
186
+ 'BUNDLE_FROZEN' => 'false',
187
+ 'BUNDLE_DEPLOYMENT' => nil,
188
+ 'BUNDLE_WITHOUT' => nil,
189
+ 'BUNDLE_CACHE_PATH' => nil,
190
+ 'BUNDLE_BIN' => nil,
191
+ 'BUNDLE_APP_CONFIG' => bundle_config,
192
+ 'RUBYOPT' => nil # Clear any -rbundler/setup from CI
193
+ )
194
+
195
+ # Use --standalone to generate a setup.rb that doesn't need bundler at runtime
196
+ # Run in a completely unbundled environment using Bundler API
197
+ cmd_script = <<~RUBY
198
+ require 'bundler'
199
+ Bundler.with_unbundled_env do
200
+ system({'BUNDLE_GEMFILE' => '#{spec[:gemfile]}', 'BUNDLE_PATH' => '#{bundle_path}', 'BUNDLE_FROZEN' => 'false', 'BUNDLE_APP_CONFIG' => '#{bundle_config}'}, 'bundle', 'install', '--standalone')
201
+ end
202
+ exit($?.success? ? 0 : 1)
203
+ RUBY
204
+
205
+ IO.popen(['ruby', '-e', cmd_script], chdir: spec[:directory], err: [:child, :out]) do |io|
206
+ io.each_line { |line| output << line }
207
+ end
208
+
209
+ @bundle_installed ||= []
210
+ @bundle_installed << spec[:gemfile] if $?.success?
211
+
212
+ {
213
+ spec: spec,
214
+ success: $?.success?,
215
+ output: output.join,
216
+ error: $?.success? ? nil : 'Bundle install failed',
217
+ bundle_config: bundle_config
218
+ }
219
+ end
220
+
221
+ def report_results(results)
222
+ skipped = results.select { |r| r[:skipped] }
223
+ failed = results.reject { |r| r[:success] || r[:skipped] }
224
+ passed = results.count { |r| r[:success] && !r[:skipped] }
225
+ total = results.size
226
+
227
+ puts
228
+ summary = "#{passed}/#{total} passed"
229
+ summary += ", #{skipped.size} skipped" if skipped.any?
230
+ puts summary
231
+
232
+ if skipped.any?
233
+ puts
234
+ puts 'Skipped:'
235
+ puts
236
+ skipped.each do |result|
237
+ puts " - #{result[:spec][:name]}"
238
+ puts " Reason: #{result[:skip_reason]}" if result[:skip_reason]
239
+ if result[:output] && !result[:output].strip.empty?
240
+ lines = result[:output].lines.last(15)
241
+ lines.each { |line| puts " #{line}" }
242
+ end
243
+ end
244
+ end
245
+
246
+ if failed.any?
247
+ puts
248
+ puts 'Failures:'
249
+ puts
250
+
251
+ failed.each_with_index do |result, idx|
252
+ puts " #{idx + 1}) #{result[:spec][:name]}"
253
+ if result[:error]
254
+ puts " Error: #{result[:error]}"
255
+ end
256
+ if result[:output] && !result[:output].strip.empty?
257
+ # Show last 30 lines of output for context
258
+ lines = result[:output].lines
259
+ if lines.size > 30
260
+ puts " ... (#{lines.size - 30} lines truncated)"
261
+ lines = lines.last(30)
262
+ end
263
+ lines.each { |line| puts " #{line}" }
264
+ end
265
+ puts
266
+ end
267
+ end
268
+
269
+ exit(failed.empty? ? 0 : 1)
270
+ end
271
+ end
272
+
273
+ if __FILE__ == $0
274
+ IntegrationRunner.new(ARGV.dup).run
275
+ end
data/bin/scenario ADDED
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Individual scenario runner for integration testing
5
+ # This script runs a single integration test file in complete isolation
6
+
7
+ require 'bundler/setup'
8
+
9
+ # Exit codes
10
+ EXIT_SUCCESS = 0
11
+ EXIT_FAILURE = 1
12
+ EXIT_TIMEOUT = 2
13
+ EXIT_SETUP_ERROR = 3
14
+
15
+ class ScenarioRunner
16
+ attr_reader :test_file
17
+
18
+ def initialize(test_file)
19
+ @test_file = test_file
20
+ @exit_code = EXIT_SUCCESS
21
+ end
22
+
23
+ def run
24
+ puts "Running: #{File.basename(test_file)}" if ENV['VERBOSE']
25
+
26
+ # Set up the scenario-specific environment
27
+ setup_scenario
28
+
29
+ # Load and run the test file
30
+ load_and_run_test
31
+
32
+ exit EXIT_SUCCESS
33
+ rescue => e
34
+ puts "FAILED: #{File.basename(test_file)} - #{e.message}" if ENV['VERBOSE']
35
+ puts e.backtrace.first(5).join("\n") if ENV['VERBOSE']
36
+ exit EXIT_FAILURE
37
+ end
38
+
39
+ private
40
+
41
+ def setup_scenario
42
+ # Each test handles its own specific requirements
43
+ require 'bundler/setup'
44
+
45
+ puts "Setting up isolated test environment" if ENV['VERBOSE']
46
+ end
47
+
48
+
49
+ def load_and_run_test
50
+ # Test file might be relative to current directory
51
+ if File.exist?(test_file)
52
+ absolute_test_path = File.expand_path(test_file)
53
+ else
54
+ # Fallback to project root resolution
55
+ project_root = File.expand_path('..', __dir__)
56
+ absolute_test_path = File.join(project_root, test_file)
57
+ end
58
+
59
+ puts "Current directory: #{Dir.pwd}" if ENV['VERBOSE']
60
+ puts "Loading test file: #{absolute_test_path}" if ENV['VERBOSE']
61
+
62
+ unless File.exist?(absolute_test_path)
63
+ raise "Test file not found: #{absolute_test_path}"
64
+ end
65
+
66
+ # Check if this is an RSpec file (contains RSpec.describe or describe)
67
+ file_content = File.read(absolute_test_path, encoding: 'UTF-8')
68
+ if file_content.match?(/\b(?:RSpec\.describe|describe)\b/)
69
+ puts "Running as RSpec test" if ENV['VERBOSE']
70
+ run_rspec_test(absolute_test_path)
71
+ else
72
+ puts "Running as plain Ruby test" if ENV['VERBOSE']
73
+ # Load integrations_helper for plain Ruby integration tests
74
+ if absolute_test_path.include?('spec/integration')
75
+ project_root = File.expand_path('..', __dir__)
76
+ integrations_helper = File.join(project_root, 'spec', 'integrations_helper.rb')
77
+ require integrations_helper
78
+ end
79
+ # Load as plain Ruby test
80
+ load absolute_test_path
81
+ end
82
+ end
83
+
84
+ def run_rspec_test(test_file_path)
85
+ # Change to project root for RSpec to find spec_helper
86
+ project_root = File.expand_path('..', __dir__)
87
+ Dir.chdir(project_root) do
88
+ # Disable SimpleCov for integration tests to avoid coverage failures
89
+ ENV['SIMPLECOV_DISABLED'] = 'true'
90
+
91
+ # Make the test file path relative to project root for RSpec
92
+ relative_test_path = test_file_path.sub("#{project_root}/", '')
93
+
94
+ puts "Running RSpec with file: #{relative_test_path}" if ENV['VERBOSE']
95
+ puts "Working directory: #{Dir.pwd}" if ENV['VERBOSE']
96
+
97
+ # Check if this test requires Rails but Rails is not available
98
+ if requires_rails?(test_file_path) && !rails_available?
99
+ puts "Skipping #{File.basename(test_file_path)} - Rails not available"
100
+ return
101
+ end
102
+
103
+ # Run RSpec with the specific test file
104
+ require 'rspec/core'
105
+
106
+ # Load integration spec_helper for integration tests
107
+ if relative_test_path.include?('spec/integration/')
108
+ require_relative '../spec/integration/spec_helper'
109
+ end
110
+
111
+ result = RSpec::Core::Runner.run([relative_test_path], $stderr, $stdout)
112
+
113
+ if result != 0
114
+ raise "RSpec failed with exit code #{result}"
115
+ end
116
+ ensure
117
+ # Clean up environment
118
+ ENV.delete('SIMPLECOV_DISABLED')
119
+ end
120
+ end
121
+
122
+ def requires_rails?(test_file_path)
123
+ # Check if the test file mentions Rails dependencies
124
+ content = File.read(test_file_path)
125
+ content.match?(/require.*rails|Rails::|ActiveJob::|ActionController::/)
126
+ end
127
+
128
+ def rails_available?
129
+ begin
130
+ require 'rails'
131
+ true
132
+ rescue LoadError
133
+ false
134
+ end
135
+ end
136
+ end
137
+
138
+ # Validate arguments
139
+ if ARGV.empty?
140
+ puts "Usage: bin/scenario <test_file>"
141
+ puts "Example: bin/scenario spec/integration/rails_integration_spec.rb"
142
+ exit EXIT_SETUP_ERROR
143
+ end
144
+
145
+ test_file = ARGV[0]
146
+
147
+ unless File.exist?(test_file)
148
+ puts "Test file not found: #{test_file}"
149
+ exit EXIT_SETUP_ERROR
150
+ end
151
+
152
+
153
+ # Run the scenario
154
+ ScenarioRunner.new(test_file).run
data/bin/shoryuken CHANGED
@@ -29,7 +29,7 @@ module Shoryuken
29
29
  method_option :delay, aliases: '-D', type: :numeric,
30
30
  desc: 'Number of seconds to pause fetching from an empty queue'
31
31
  def start
32
- opts = options.to_h.symbolize_keys
32
+ opts = options.to_h.transform_keys(&:to_sym)
33
33
 
34
34
  say '[DEPRECATED] Please use --config instead of --config-file', :yellow if opts[:config_file]
35
35
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Shoryuken
4
- module ActiveJobExtensions
4
+ module ActiveJob
5
5
  # Adds an accessor for SQS SendMessage parameters on ActiveJob jobs
6
6
  # (instances of ActiveJob::Base). Shoryuken ActiveJob queue adapters use
7
7
  # these parameters when enqueueing jobs; other adapters can ignore them.
@@ -9,6 +9,7 @@ module Shoryuken
9
9
  extend ActiveSupport::Concern
10
10
 
11
11
  included do
12
+ # @return [Hash] the SQS send message parameters
12
13
  attr_accessor :sqs_send_message_parameters
13
14
  end
14
15
  end
@@ -17,12 +18,22 @@ module Shoryuken
17
18
  # to the empty hash, and populates it whenever `#enqueue` is called, such
18
19
  # as when using ActiveJob::Base.set.
19
20
  module SQSSendMessageParametersSupport
21
+ # Initializes a new ActiveJob instance with empty SQS parameters
22
+ #
23
+ # @param arguments [Array] the job arguments
20
24
  def initialize(*arguments)
21
25
  super(*arguments)
22
26
  self.sqs_send_message_parameters = {}
23
27
  end
24
- ruby2_keywords(:initialize) if respond_to?(:ruby2_keywords, true)
25
28
 
29
+ # Enqueues the job with optional SQS-specific parameters
30
+ #
31
+ # @param options [Hash] enqueue options
32
+ # @option options [Hash] :message_attributes custom SQS message attributes
33
+ # @option options [Hash] :message_system_attributes system attributes
34
+ # @option options [String] :message_deduplication_id FIFO deduplication ID
35
+ # @option options [String] :message_group_id FIFO message group ID
36
+ # @return [Object] the enqueue result
26
37
  def enqueue(options = {})
27
38
  sqs_options = options.extract! :message_attributes,
28
39
  :message_system_attributes,
@@ -36,5 +47,5 @@ module Shoryuken
36
47
  end
37
48
  end
38
49
 
39
- ActiveJob::Base.include Shoryuken::ActiveJobExtensions::SQSSendMessageParametersAccessor
40
- ActiveJob::Base.prepend Shoryuken::ActiveJobExtensions::SQSSendMessageParametersSupport
50
+ ActiveJob::Base.include Shoryuken::ActiveJob::SQSSendMessageParametersAccessor
51
+ ActiveJob::Base.prepend Shoryuken::ActiveJob::SQSSendMessageParametersSupport