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.
- checksums.yaml +4 -4
- data/.github/workflows/push.yml +3 -3
- data/.github/workflows/specs.yml +32 -5
- data/.github/workflows/verify-action-pins.yml +1 -1
- data/.ruby-version +1 -1
- data/.yard-lint.yml +29 -1
- data/CHANGELOG.md +171 -0
- data/Gemfile.lint.lock +8 -7
- data/bin/cli/sqs.rb +67 -3
- data/bin/integrations +52 -34
- data/lib/active_job/extensions.rb +5 -8
- data/lib/active_job/queue_adapters/shoryuken_adapter.rb +6 -5
- data/lib/shoryuken/active_job/current_attributes.rb +33 -7
- data/lib/shoryuken/body_parser.rb +1 -2
- data/lib/shoryuken/client.rb +15 -6
- data/lib/shoryuken/default_exception_handler.rb +2 -3
- data/lib/shoryuken/errors.rb +2 -4
- data/lib/shoryuken/fetcher.rb +7 -1
- data/lib/shoryuken/helpers/atomic_counter.rb +6 -9
- data/lib/shoryuken/helpers/atomic_hash.rb +9 -15
- data/lib/shoryuken/helpers/hash_utils.rb +5 -7
- data/lib/shoryuken/helpers/string_utils.rb +6 -8
- data/lib/shoryuken/helpers/timer_task.rb +19 -2
- data/lib/shoryuken/inline_message.rb +3 -4
- data/lib/shoryuken/launcher.rb +19 -8
- data/lib/shoryuken/manager.rb +65 -6
- data/lib/shoryuken/message.rb +2 -3
- data/lib/shoryuken/middleware/chain.rb +7 -13
- data/lib/shoryuken/middleware/entry.rb +1 -2
- data/lib/shoryuken/middleware/server/auto_extend_visibility.rb +30 -2
- data/lib/shoryuken/middleware/server/exponential_backoff_retry.rb +40 -18
- data/lib/shoryuken/middleware/server/non_retryable_exception.rb +17 -8
- data/lib/shoryuken/middleware/server/timing.rb +2 -3
- data/lib/shoryuken/options.rb +37 -4
- data/lib/shoryuken/polling/base_strategy.rb +16 -23
- data/lib/shoryuken/polling/queue_configuration.rb +8 -12
- data/lib/shoryuken/polling/strict_priority.rb +26 -14
- data/lib/shoryuken/polling/weighted_round_robin.rb +40 -27
- data/lib/shoryuken/queue.rb +41 -4
- data/lib/shoryuken/util.rb +4 -1
- data/lib/shoryuken/version.rb +1 -1
- data/lib/shoryuken/worker/default_executor.rb +11 -5
- data/lib/shoryuken/worker/inline_executor.rb +10 -5
- data/lib/shoryuken/worker.rb +15 -13
- data/lib/shoryuken.rb +6 -3
- data/renovate.json +16 -2
- data/shoryuken.gemspec +9 -0
- data/spec/integration/active_job/current_attributes/cross_job_reset_spec.rb +47 -0
- data/spec/integration/active_job/current_attributes/incremental_persist_spec.rb +76 -0
- data/spec/integration/active_job/fifo_dedup_opt_out/fifo_dedup_opt_out_spec.rb +67 -0
- data/spec/integration/auto_extend_visibility/short_visibility_timeout_spec.rb +52 -0
- data/spec/integration/cli/find_all_spec.rb +120 -0
- data/spec/integration/concurrent_processing/processor_accounting_spec.rb +94 -0
- data/spec/integration/fifo_ordering/fifo_max_messages_cap_spec.rb +96 -0
- data/spec/integration/launcher/double_graceful_stop_spec.rb +71 -0
- data/spec/integration/launcher/embedded_dispatch_error_spec.rb +85 -0
- data/spec/integration/launcher/global_executor_preserved_spec.rb +76 -0
- data/spec/integration/launcher/graceful_stop_timeout_spec.rb +74 -0
- data/spec/integration/message_operations/partial_batch_delete_spec.rb +67 -0
- data/spec/integration/non_retryable_exception/non_retryable_exception_spec.rb +1 -1
- data/spec/integration/non_retryable_exception/with_retry_intervals_spec.rb +115 -0
- data/spec/integrations_helper.rb +10 -9
- data/spec/lib/shoryuken/client_spec.rb +62 -0
- data/spec/lib/shoryuken/fetcher_spec.rb +13 -0
- data/spec/lib/shoryuken/helpers/timer_task_spec.rb +24 -0
- data/spec/lib/shoryuken/launcher_spec.rb +38 -0
- data/spec/lib/shoryuken/manager_spec.rb +147 -0
- data/spec/lib/shoryuken/middleware/server/auto_extend_visibility_spec.rb +35 -0
- data/spec/lib/shoryuken/middleware/server/exponential_backoff_retry_spec.rb +88 -0
- data/spec/lib/shoryuken/polling/strict_priority_spec.rb +25 -0
- data/spec/lib/shoryuken/polling/weighted_round_robin_spec.rb +50 -0
- data/spec/lib/shoryuken/queue_spec.rb +123 -0
- data/spec/lib/shoryuken/util_spec.rb +26 -0
- data/spec/lib/shoryuken/worker/default_executor_spec.rb +13 -0
- data/spec/lib/shoryuken/worker/inline_executor_spec.rb +12 -0
- data/spec/shared_examples_for_active_job.rb +18 -0
- data/spec/spec_helper.rb +40 -20
- metadata +35 -3
data/spec/integrations_helper.rb
CHANGED
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
# Integration test helper for process-isolated testing
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
Warning[:deprecated] = true
|
|
5
|
+
require 'warning'
|
|
6
|
+
|
|
8
7
|
$VERBOSE = true
|
|
9
8
|
|
|
10
|
-
|
|
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
|
-
|
|
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
|