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
@@ -2,12 +2,15 @@
2
2
 
3
3
  # Integration test helper for process-isolated testing
4
4
 
5
- # Enable Ruby warnings to catch deprecations and potential issues
6
- Warning[:performance] = true if RUBY_VERSION >= '3.3'
7
- Warning[:deprecated] = true
5
+ require 'warning'
6
+
8
7
  $VERBOSE = true
9
8
 
10
- require 'warning'
9
+ if Warning.respond_to?(:categories)
10
+ (Warning.categories - %i[experimental]).each do |cat|
11
+ Warning[cat] = true
12
+ end
13
+ end
11
14
 
12
15
  # Process warnings and raise on unexpected ones from our code
13
16
  Warning.process do |warning|
@@ -17,10 +20,6 @@ Warning.process do |warning|
17
20
  # Filter out warnings we don't care about in specs
18
21
  next if warning.include?('_spec')
19
22
 
20
- # We redefine methods to simulate various scenarios in tests
21
- next if warning.include?('previous definition of')
22
- next if warning.include?('method redefined')
23
-
24
23
  # Ignore vendor directory
25
24
  next if warning.include?('vendor/')
26
25
 
@@ -191,7 +190,9 @@ module IntegrationsHelper
191
190
  launcher.start
192
191
  Timeout.timeout(timeout) { sleep 0.5 until yield }
193
192
  ensure
194
- launcher.stop
193
+ # Guard stop with a hard deadline so a stuck dispatch thread (e.g. an SQS
194
+ # HTTP call that never returns) can't block the spec process forever.
195
+ Timeout.timeout(30) { launcher.stop }
195
196
  end
196
197
 
197
198
  # Simple mock object
@@ -17,5 +17,67 @@ RSpec.describe Shoryuken::Client do
17
17
  expect(Shoryuken::Client.queues(queue_name).url).to eq queue_url
18
18
  expect(Shoryuken::Client.queues(queue_name).url).to eq queue_url
19
19
  end
20
+
21
+ it 'constructs each queue only once under concurrent first access' do
22
+ allow(described_class).to receive(:sqs).and_return(sqs)
23
+
24
+ construction_count = Shoryuken::Helpers::AtomicCounter.new(0)
25
+ allow(Shoryuken::Queue).to receive(:new) do
26
+ construction_count.increment
27
+ # Mimic the SQS API latency during construction. sleep releases the GVL,
28
+ # so without synchronization every concurrent caller builds its own queue.
29
+ sleep 0.05
30
+ instance_double(Shoryuken::Queue)
31
+ end
32
+
33
+ threads = Array.new(10) { Thread.new { described_class.queues('concurrent') } }
34
+ threads.each(&:join)
35
+
36
+ expect(construction_count.value).to eq(1)
37
+ end
38
+
39
+ it 'rebuilds cached queues after the sqs client is replaced' do
40
+ allow(described_class).to receive(:sqs).and_return(sqs)
41
+
42
+ construction_count = Shoryuken::Helpers::AtomicCounter.new(0)
43
+ allow(Shoryuken::Queue).to receive(:new) do
44
+ construction_count.increment
45
+ instance_double(Shoryuken::Queue)
46
+ end
47
+
48
+ described_class.queues(queue_name)
49
+ described_class.queues(queue_name)
50
+ expect(construction_count.value).to eq(1)
51
+
52
+ described_class.sqs = sqs
53
+
54
+ described_class.queues(queue_name)
55
+ expect(construction_count.value).to eq(2)
56
+ end
57
+
58
+ it 'does not cache a queue built with the previous client while it is being replaced' do
59
+ old_sqs = :old_client
60
+ new_sqs = :new_client
61
+
62
+ described_class.sqs = old_sqs
63
+
64
+ allow(Shoryuken::Queue).to receive(:new) do |client, _name|
65
+ instance_double(Shoryuken::Queue, client: client)
66
+ end
67
+
68
+ # Widen the window between resetting the cache and installing the new client so a concurrent
69
+ # `queues` call would cache a queue built with the old client unless the swap is atomic.
70
+ allow(Shoryuken).to receive(:sqs_client=).and_wrap_original do |original, client|
71
+ sleep 0.05
72
+ original.call(client)
73
+ end
74
+
75
+ replacer = Thread.new { described_class.sqs = new_sqs }
76
+ sleep 0.01
77
+ reader = Thread.new { described_class.queues('concurrent') }
78
+ [replacer, reader].each(&:join)
79
+
80
+ expect(described_class.queues('concurrent').client).to eq(new_sqs)
81
+ end
20
82
  end
21
83
  end
@@ -113,6 +113,19 @@ RSpec.describe Shoryuken::Fetcher do
113
113
  subject.fetch(queue_config, limit)
114
114
  end
115
115
 
116
+ it 'caps at one message even when the queue config requests more' do
117
+ # A custom polling strategy can put max_number_of_messages in the
118
+ # QueueConfiguration options; the FIFO one-at-a-time guard must still win
119
+ # so SQS can't return several messages from the same group.
120
+ config = Shoryuken::Polling::QueueConfiguration.new(queue_name, max_number_of_messages: 10)
121
+
122
+ allow(Shoryuken::Client).to receive(:queues).with(queue_name).and_return(queue)
123
+ expect(queue).to receive(:receive_messages)
124
+ .with(hash_including(max_number_of_messages: 1)).and_return([])
125
+
126
+ subject.fetch(config, limit)
127
+ end
128
+
116
129
  context 'with batch=true' do
117
130
  it 'polls the provided limit' do
118
131
  # see https://github.com/ruby-shoryuken/shoryuken/pull/530
@@ -295,4 +295,28 @@ RSpec.describe Shoryuken::Helpers::TimerTask do
295
295
  expect(false_count).to eq(4)
296
296
  end
297
297
  end
298
+
299
+ describe 'thread priority' do
300
+ it 'does not inherit a lowered priority from the creating thread' do
301
+ observed = ::Queue.new
302
+
303
+ # Shoryuken starts the visibility-extension timer from a worker thread
304
+ # whose priority is lowered to Shoryuken.thread_priority (default -1).
305
+ # The timer thread must not inherit that lowered priority, otherwise a
306
+ # delayed extension under load can miss the visibility timeout and let the
307
+ # message be redelivered (double processed).
308
+ creator = Thread.new do
309
+ Thread.current.priority = -1
310
+ timer = described_class.new(execution_interval: 0.05) do
311
+ observed << Thread.current.priority
312
+ end
313
+ timer.execute
314
+ sleep(0.15)
315
+ timer.kill
316
+ end
317
+ creator.join
318
+
319
+ expect(observed.pop).to eq(0)
320
+ end
321
+ end
298
322
  end
@@ -78,6 +78,22 @@ RSpec.describe Shoryuken::Launcher do
78
78
  expect(first_group_manager).to have_received(:stop_new_dispatching)
79
79
  expect(second_group_manager).to have_received(:stop_new_dispatching)
80
80
  end
81
+
82
+ it 'bounds the wait for termination by the configured timeout' do
83
+ allow(executor).to receive(:shutdown)
84
+ expect(executor).to receive(:wait_for_termination).with(Shoryuken.options[:timeout]).and_return(true)
85
+ expect(executor).not_to receive(:kill)
86
+
87
+ subject.stop
88
+ end
89
+
90
+ it 'kills the executor when workers do not finish within the timeout' do
91
+ allow(executor).to receive(:shutdown)
92
+ allow(executor).to receive(:wait_for_termination).and_return(false)
93
+ expect(executor).to receive(:kill)
94
+
95
+ subject.stop
96
+ end
81
97
  end
82
98
 
83
99
  describe '#stop!' do
@@ -102,6 +118,28 @@ RSpec.describe Shoryuken::Launcher do
102
118
  end
103
119
  end
104
120
 
121
+ describe 'executor ownership' do
122
+ context 'when no launcher_executor is configured' do
123
+ before { allow(Shoryuken).to receive(:launcher_executor).and_return(nil) }
124
+
125
+ it 'uses a dedicated executor rather than the process-global IO executor' do
126
+ expect(subject.send(:executor)).not_to be(Concurrent.global_io_executor)
127
+ end
128
+
129
+ it 'does not shut down or kill the global IO executor on stop!' do
130
+ allow(first_group_manager).to receive(:stop_new_dispatching)
131
+ allow(second_group_manager).to receive(:stop_new_dispatching)
132
+
133
+ subject.stop!
134
+
135
+ # Verify by inspecting state after the call rather than mocking methods
136
+ # on the process-global singleton: RSpec proxies on a singleton used by
137
+ # background concurrent-ruby threads can deadlock on Ruby 3.2.
138
+ expect(Concurrent.global_io_executor).to be_running
139
+ end
140
+ end
141
+ end
142
+
105
143
  describe '#stopping?' do
106
144
  it 'returns false by default' do
107
145
  expect(subject.stopping?).to be false
@@ -34,6 +34,50 @@ RSpec.describe Shoryuken::Manager do
34
34
  end
35
35
  end
36
36
 
37
+ describe '#await_dispatching_in_progress' do
38
+ it 'returns for repeated graceful stops (e.g. TSTP followed by USR1)' do
39
+ subject.stop_new_dispatching
40
+ # The dispatch loop observes the stop flag and releases all waiters
41
+ subject.start
42
+
43
+ waiter = Thread.new do
44
+ subject.await_dispatching_in_progress
45
+ subject.await_dispatching_in_progress
46
+ end
47
+
48
+ expect(waiter.join(5)).to eq(waiter), 'await_dispatching_in_progress deadlocked on a repeated stop'
49
+ end
50
+
51
+ it 'returns when stopped before the dispatch loop ever runs' do
52
+ # No #start here: the dispatch loop never runs, so there is nothing to
53
+ # observe the stop flag and close the signal - stop_new_dispatching must
54
+ # release waiters itself.
55
+ subject.stop_new_dispatching
56
+
57
+ waiter = Thread.new { subject.await_dispatching_in_progress }
58
+
59
+ expect(waiter.join(5)).to eq(waiter), 'await_dispatching_in_progress deadlocked when start never ran'
60
+ end
61
+
62
+ context 'when the executor rejects the dispatch post' do
63
+ let(:executor) do
64
+ double('executor', running?: true).tap do |rejecting_executor|
65
+ allow(rejecting_executor).to receive(:post).and_raise(Concurrent::RejectedExecutionError)
66
+ end
67
+ end
68
+
69
+ it 'releases waiters instead of leaving the signal queue unsignaled' do
70
+ # A hard stop can shut the executor down between the running? check and
71
+ # the post; the dispatch chain must still release stop waiters
72
+ subject.start
73
+
74
+ waiter = Thread.new { subject.await_dispatching_in_progress }
75
+
76
+ expect(waiter.join(5)).to eq(waiter), 'await_dispatching_in_progress deadlocked after a rejected post'
77
+ end
78
+ end
79
+ end
80
+
37
81
  describe '#start' do
38
82
  before do
39
83
  # prevent dispatch loop
@@ -153,6 +197,81 @@ RSpec.describe Shoryuken::Manager do
153
197
  end
154
198
  end
155
199
 
200
+ describe '#assign' do
201
+ let(:sqs_msg) { double(Shoryuken::Message, message_id: SecureRandom.uuid) }
202
+ let(:sqs_queue) { double(Shoryuken::Queue, fifo?: false) }
203
+
204
+ before do
205
+ allow(Shoryuken::Client).to receive(:queues).with(queue).and_return(sqs_queue)
206
+ allow(Shoryuken::Processor).to receive(:process)
207
+ end
208
+
209
+ it 'runs completion exactly once on success' do
210
+ expect(subject).to receive(:processor_done).with(queue).once.and_call_original
211
+
212
+ subject.send(:assign, queue, sqs_msg)
213
+
214
+ expect(subject.send(:busy)).to eq(0)
215
+ end
216
+
217
+ it 'runs completion exactly once when processing raises' do
218
+ allow(Shoryuken::Processor).to receive(:process).and_raise('processing failed')
219
+
220
+ expect(subject).to receive(:processor_done).with(queue).once.and_call_original
221
+
222
+ subject.send(:assign, queue, sqs_msg)
223
+
224
+ expect(subject.send(:busy)).to eq(0)
225
+ end
226
+
227
+ it 'does not double decrement the busy counter when completion raises' do
228
+ allow(sqs_queue).to receive(:fifo?).and_return(true)
229
+ allow(polling_strategy).to receive(:message_processed).and_raise('callback failed')
230
+
231
+ subject.send(:assign, queue, sqs_msg)
232
+
233
+ expect(polling_strategy).to have_received(:message_processed).once
234
+ expect(subject.send(:busy)).to eq(0)
235
+ end
236
+
237
+ context 'when the executor rejects the worker post' do
238
+ # A real ExecutorService instance (Concurrent::Promise validates the
239
+ # executor type), with running? stubbed true so #assign proceeds past its
240
+ # guard and post rejecting - exactly the shutdown race / saturated
241
+ # bounded launcher_executor case. ImmediateExecutor spawns no background
242
+ # thread, so nothing lingers at teardown.
243
+ let(:executor) do
244
+ Concurrent::ImmediateExecutor.new.tap do |rejecting_executor|
245
+ allow(rejecting_executor).to receive(:running?).and_return(true)
246
+ allow(rejecting_executor).to receive(:post).and_raise(Concurrent::RejectedExecutionError)
247
+ end
248
+ end
249
+
250
+ it 'rolls back the busy counter and does not leak it' do
251
+ # The promise body never runs, so completion must not run either
252
+ # (decrement directly, and no FIFO message_processed callback).
253
+ expect(subject).not_to receive(:processor_done)
254
+
255
+ expect { subject.send(:assign, queue, sqs_msg) }.not_to raise_error
256
+
257
+ expect(subject.send(:busy)).to eq(0)
258
+ end
259
+
260
+ it 'does not run the FIFO message_processed callback' do
261
+ # The message was never processed, so the rejection rollback must
262
+ # decrement directly and never reach the FIFO bookkeeping - even for a
263
+ # FIFO queue. Guards against a refactor routing rollback through
264
+ # processor_done.
265
+ allow(sqs_queue).to receive(:fifo?).and_return(true)
266
+ expect(polling_strategy).not_to receive(:message_processed)
267
+
268
+ subject.send(:assign, queue, sqs_msg)
269
+
270
+ expect(subject.send(:busy)).to eq(0)
271
+ end
272
+ end
273
+ end
274
+
156
275
  describe '#processor_done' do
157
276
  let(:sqs_queue) { double Shoryuken::Queue }
158
277
 
@@ -177,6 +296,34 @@ RSpec.describe Shoryuken::Manager do
177
296
  end
178
297
  end
179
298
 
299
+ describe '#handle_dispatch_error' do
300
+ let(:error) { StandardError.new('boom') }
301
+
302
+ context 'when running under the CLI (server mode)' do
303
+ before { allow(Shoryuken).to receive(:server?).and_return(true) }
304
+
305
+ it 'stops the manager and signals the process to shut down' do
306
+ expect(Process).to receive(:kill).with('USR1', Process.pid)
307
+
308
+ subject.send(:handle_dispatch_error, error)
309
+
310
+ expect(subject.running?).to be false
311
+ end
312
+ end
313
+
314
+ context 'when embedded (no Runner)' do
315
+ before { allow(Shoryuken).to receive(:server?).and_return(false) }
316
+
317
+ it 'stops the manager without signalling the process' do
318
+ expect(Process).not_to receive(:kill)
319
+
320
+ subject.send(:handle_dispatch_error, error)
321
+
322
+ expect(subject.running?).to be false
323
+ end
324
+ end
325
+ end
326
+
180
327
  describe '#running?' do
181
328
  context 'when the executor is running' do
182
329
  before do
@@ -114,4 +114,39 @@ RSpec.describe Shoryuken::Middleware::Server::AutoExtendVisibility do
114
114
  Runner.new.run_and_sleep(TestWorker.new, queue, sqs_msg, visibility_timeout)
115
115
  end
116
116
  end
117
+
118
+ context 'when the visibility timeout is too short for the upfront margin' do
119
+ # interval would be visibility_timeout - extend_upfront == 0, which used to
120
+ # make TimerTask raise before the worker ran.
121
+ let(:visibility_timeout) { extend_upfront }
122
+
123
+ it 'runs the worker instead of raising' do
124
+ TestWorker.get_shoryuken_options['auto_visibility_timeout'] = true
125
+ allow(sqs_msg).to receive(:queue) { sqs_queue }
126
+ allow(sqs_msg).to receive(:message_id).and_return('test-message-id')
127
+ allow(sqs_msg).to receive(:change_visibility)
128
+
129
+ ran = false
130
+ subject.call(TestWorker.new, queue, sqs_msg, sqs_msg.body) { ran = true }
131
+
132
+ expect(ran).to be true
133
+ end
134
+ end
135
+
136
+ context 'when the visibility timeout is zero' do
137
+ let(:visibility_timeout) { 0 }
138
+
139
+ it 'warns and runs the worker without scheduling an extension' do
140
+ TestWorker.get_shoryuken_options['auto_visibility_timeout'] = true
141
+ allow(sqs_msg).to receive(:queue) { sqs_queue }
142
+ allow(sqs_msg).to receive(:message_id).and_return('test-message-id')
143
+ expect(sqs_msg).not_to receive(:change_visibility)
144
+ expect(Shoryuken.logger).to receive(:warn)
145
+
146
+ ran = false
147
+ subject.call(TestWorker.new, queue, sqs_msg, sqs_msg.body) { ran = true }
148
+
149
+ expect(ran).to be true
150
+ end
151
+ end
117
152
  end
@@ -17,6 +17,21 @@ RSpec.describe Shoryuken::Middleware::Server::ExponentialBackoffRetry do
17
17
  it 'yields' do
18
18
  expect { |b| subject.call(TestWorker.new, nil, [], nil, &b) }.to yield_control
19
19
  end
20
+
21
+ it 'does not mask the original error when the batch raises' do
22
+ TestWorker.get_shoryuken_options['retry_intervals'] = [300]
23
+
24
+ # Use a logger that actually evaluates lazy log blocks (StringIO, not
25
+ # IO::NULL which skips them), as the default production level does - so a
26
+ # stray message_id/attributes call on the batch Array would surface.
27
+ # Batches aren't backoff-retried, so the original error must propagate
28
+ # rather than a NoMethodError from calling message APIs on the Array.
29
+ allow(subject).to receive(:logger).and_return(Logger.new(StringIO.new, level: Logger::DEBUG))
30
+
31
+ expect {
32
+ subject.call(TestWorker.new, nil, [sqs_msg], nil) { raise 'original batch error' }
33
+ }.to raise_error(RuntimeError, 'original batch error')
34
+ end
20
35
  end
21
36
 
22
37
  context 'when no exception' do
@@ -92,6 +107,62 @@ RSpec.describe Shoryuken::Middleware::Server::ExponentialBackoffRetry do
92
107
 
93
108
  expect { subject.call(TestWorker.new, queue, sqs_msg, sqs_msg.body) { raise 'failed' } }.not_to raise_error
94
109
  end
110
+
111
+ it 'keeps reusing the last interval for far-later attempts (does not give up)' do
112
+ TestWorker.get_shoryuken_options['retry_intervals'] = [300, 1800]
113
+
114
+ allow(sqs_msg).to receive(:attributes) { { 'ApproximateReceiveCount' => 10 } }
115
+ allow(sqs_msg).to receive(:queue) { sqs_queue }
116
+ expect(sqs_msg).to receive(:change_visibility).with(visibility_timeout: 1800)
117
+
118
+ expect { subject.call(TestWorker.new, queue, sqs_msg, sqs_msg.body) { raise 'failed' } }.not_to raise_error
119
+ end
120
+ end
121
+
122
+ context 'and the exception is non-retryable' do
123
+ it 're-raises so NonRetryableException can delete the message' do
124
+ TestWorker.get_shoryuken_options['retry_intervals'] = [300, 1800]
125
+ TestWorker.get_shoryuken_options['non_retryable_exceptions'] = [ArgumentError]
126
+
127
+ expect(sqs_msg).not_to receive(:change_visibility)
128
+
129
+ expect {
130
+ subject.call(TestWorker.new, queue, sqs_msg, sqs_msg.body) { raise ArgumentError, 'permanently invalid' }
131
+ }.to raise_error(ArgumentError, 'permanently invalid')
132
+ end
133
+
134
+ it 're-raises when a non_retryable_exceptions lambda returns true' do
135
+ TestWorker.get_shoryuken_options['retry_intervals'] = [300, 1800]
136
+ TestWorker.get_shoryuken_options['non_retryable_exceptions'] = ->(e) { e.message.include?('permanent') }
137
+
138
+ expect(sqs_msg).not_to receive(:change_visibility)
139
+
140
+ expect {
141
+ subject.call(TestWorker.new, queue, sqs_msg, sqs_msg.body) { raise 'permanent failure' }
142
+ }.to raise_error(RuntimeError, 'permanent failure')
143
+ end
144
+
145
+ it 'still retries when a non_retryable_exceptions lambda returns false' do
146
+ TestWorker.get_shoryuken_options['retry_intervals'] = [300, 1800]
147
+ TestWorker.get_shoryuken_options['non_retryable_exceptions'] = ->(e) { e.message.include?('permanent') }
148
+
149
+ allow(sqs_msg).to receive(:queue) { sqs_queue }
150
+ expect(sqs_msg).to receive(:change_visibility).with(visibility_timeout: 300)
151
+
152
+ expect { subject.call(TestWorker.new, queue, sqs_msg, sqs_msg.body) { raise 'transient failure' } }
153
+ .not_to raise_error
154
+ end
155
+
156
+ it 'still retries exceptions not in the non-retryable list' do
157
+ TestWorker.get_shoryuken_options['retry_intervals'] = [300, 1800]
158
+ TestWorker.get_shoryuken_options['non_retryable_exceptions'] = [ArgumentError]
159
+
160
+ allow(sqs_msg).to receive(:queue) { sqs_queue }
161
+ expect(sqs_msg).to receive(:change_visibility).with(visibility_timeout: 300)
162
+
163
+ expect { subject.call(TestWorker.new, queue, sqs_msg, sqs_msg.body) { raise 'transient failure' } }
164
+ .not_to raise_error
165
+ end
95
166
  end
96
167
 
97
168
  it 'limits the visibility timeout to 12 hours' do
@@ -102,5 +173,22 @@ RSpec.describe Shoryuken::Middleware::Server::ExponentialBackoffRetry do
102
173
 
103
174
  expect { subject.call(TestWorker.new, queue, sqs_msg, sqs_msg.body) { raise 'failed' } }.not_to raise_error
104
175
  end
176
+
177
+ it 'never sets a negative visibility timeout for jobs longer than the SQS ceiling' do
178
+ started_at = Time.now - 43_300 # ran longer than the 12h SQS maximum
179
+
180
+ expect(subject.send(:next_visibility_timeout, 300, started_at)).to eq(0)
181
+ end
182
+
183
+ it 'does not mask the original error when rescheduling fails' do
184
+ TestWorker.get_shoryuken_options['retry_intervals'] = [300]
185
+
186
+ # e.g. an expired receipt handle - change_visibility raises
187
+ allow(sqs_msg).to receive(:change_visibility).and_raise(StandardError, 'visibility boom')
188
+
189
+ expect {
190
+ subject.call(TestWorker.new, queue, sqs_msg, sqs_msg.body) { raise 'original worker error' }
191
+ }.to raise_error(RuntimeError, 'original worker error')
192
+ end
105
193
  end
106
194
  end
@@ -155,4 +155,29 @@ RSpec.describe Shoryuken::Polling::StrictPriority do
155
155
  expect(strategy.active_queues).to eq([[queue1, 2], [queue2, 1]])
156
156
  end
157
157
  end
158
+
159
+ describe 'thread safety' do
160
+ it 'serializes concurrent next_queue, messages_found and message_processed' do
161
+ strategy = Shoryuken::Polling::StrictPriority.new([queue1, queue2], 10)
162
+
163
+ errors = []
164
+ errors_mutex = Mutex.new
165
+
166
+ threads = Array.new(6) do
167
+ Thread.new do
168
+ 50.times do |i|
169
+ strategy.next_queue
170
+ strategy.messages_found(queue1, i.even? ? 0 : 1)
171
+ strategy.message_processed(queue1)
172
+ strategy.active_queues
173
+ end
174
+ rescue => e
175
+ errors_mutex.synchronize { errors << e }
176
+ end
177
+ end
178
+ threads.each(&:join)
179
+
180
+ expect(errors).to be_empty
181
+ end
182
+ end
158
183
  end
@@ -138,5 +138,55 @@ RSpec.describe Shoryuken::Polling::WeightedRoundRobin do
138
138
  expect(subject.next_queue).to eq(queue1) # implicitly unpauses queue2
139
139
  expect(subject.active_queues).to eq([[queue1, 2], [queue2, 1]])
140
140
  end
141
+
142
+ it 'unpauses a processed queue stuck behind an earlier-paused queue' do
143
+ queues << queue1
144
+ queues << queue2
145
+
146
+ allow(subject).to receive(:delay).and_return(10)
147
+ now = Time.now
148
+ allow(Time).to receive(:now).and_return(now)
149
+
150
+ subject.messages_found(queue1, 0) # queue1 paused until now + 10
151
+
152
+ now += 1
153
+ allow(Time).to receive(:now).and_return(now)
154
+ subject.messages_found(queue2, 0) # queue2 paused until now + 10 (after queue1)
155
+
156
+ # both queues paused, nothing pollable yet
157
+ expect(subject.next_queue).to eq(nil)
158
+
159
+ # queue2 finishes processing and is marked ready while queue1 is still paused
160
+ subject.message_processed(queue2)
161
+
162
+ # queue2 must be pollable even though the earlier-paused queue1 is still paused
163
+ expect(subject.next_queue).to eq(queue2)
164
+ end
165
+ end
166
+
167
+ describe 'thread safety' do
168
+ it 'serializes concurrent next_queue, messages_found and message_processed' do
169
+ queues << queue1
170
+ queues << queue2
171
+
172
+ errors = []
173
+ errors_mutex = Mutex.new
174
+
175
+ threads = Array.new(6) do
176
+ Thread.new do
177
+ 50.times do |i|
178
+ subject.next_queue
179
+ subject.messages_found(queue1, i.even? ? 0 : 1)
180
+ subject.message_processed(queue1)
181
+ subject.active_queues
182
+ end
183
+ rescue => e
184
+ errors_mutex.synchronize { errors << e }
185
+ end
186
+ end
187
+ threads.each(&:join)
188
+
189
+ expect(errors).to be_empty
190
+ end
141
191
  end
142
192
  end