shoryuken 7.0.2 → 7.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/CHANGELOG.md +107 -0
- data/bin/cli/sqs.rb +7 -0
- data/bin/integrations +52 -34
- data/lib/active_job/queue_adapters/shoryuken_adapter.rb +3 -1
- data/lib/shoryuken/active_job/current_attributes.rb +35 -7
- data/lib/shoryuken/fetcher.rb +7 -1
- data/lib/shoryuken/helpers/timer_task.rb +19 -2
- data/lib/shoryuken/launcher.rb +21 -5
- data/lib/shoryuken/manager.rb +38 -5
- data/lib/shoryuken/middleware/server/auto_extend_visibility.rb +32 -2
- data/lib/shoryuken/middleware/server/exponential_backoff_retry.rb +8 -3
- data/lib/shoryuken/middleware/server/non_retryable_exception.rb +17 -8
- data/lib/shoryuken/options.rb +12 -1
- data/lib/shoryuken/polling/strict_priority.rb +26 -14
- data/lib/shoryuken/polling/weighted_round_robin.rb +41 -27
- data/lib/shoryuken/queue.rb +8 -1
- data/lib/shoryuken/util.rb +4 -1
- data/lib/shoryuken/version.rb +1 -1
- data/lib/shoryuken/worker.rb +5 -1
- data/lib/shoryuken.rb +2 -0
- data/renovate.json +16 -2
- 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/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/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 +99 -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 +56 -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 +37 -0
- data/spec/lib/shoryuken/util_spec.rb +26 -0
- data/spec/shared_examples_for_active_job.rb +18 -0
- data/spec/spec_helper.rb +26 -7
- metadata +26 -2
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# This spec tests that auto_visibility_timeout still processes messages when the
|
|
4
|
+
# queue's visibility timeout is short (<= EXTEND_UPFRONT_SECONDS).
|
|
5
|
+
#
|
|
6
|
+
# AutoExtendVisibility schedules a TimerTask at execution_interval =
|
|
7
|
+
# visibility_timeout - EXTEND_UPFRONT_SECONDS (5s). When the queue's visibility
|
|
8
|
+
# timeout is <= 5s that interval is <= 0, and TimerTask#initialize raises
|
|
9
|
+
# ArgumentError *before* the worker runs - so every receive fails and the
|
|
10
|
+
# message is reprocessed (and re-fails) until it hits a DLQ.
|
|
11
|
+
#
|
|
12
|
+
# Expected behavior: a short visibility timeout no longer breaks processing; the
|
|
13
|
+
# extension interval is clamped to a positive value and the worker runs normally.
|
|
14
|
+
#
|
|
15
|
+
# Regression: auto_visibility_timeout + a <= 5s queue visibility timeout broke
|
|
16
|
+
# processing entirely.
|
|
17
|
+
|
|
18
|
+
require 'timeout'
|
|
19
|
+
|
|
20
|
+
setup_sqs
|
|
21
|
+
|
|
22
|
+
DT.clear
|
|
23
|
+
|
|
24
|
+
queue_name = DT.queue
|
|
25
|
+
# 5s visibility timeout -> interval would be 5 - 5 = 0 -> TimerTask raised pre-fix.
|
|
26
|
+
create_test_queue(queue_name, attributes: { 'VisibilityTimeout' => '5' })
|
|
27
|
+
Shoryuken.add_group('default', 1)
|
|
28
|
+
Shoryuken.add_queue(queue_name, 1, 'default')
|
|
29
|
+
|
|
30
|
+
short_vt_worker = Class.new do
|
|
31
|
+
include Shoryuken::Worker
|
|
32
|
+
|
|
33
|
+
shoryuken_options auto_delete: true, auto_visibility_timeout: true
|
|
34
|
+
|
|
35
|
+
def perform(_sqs_msg, body)
|
|
36
|
+
DT[:processed] << body
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
short_vt_worker.get_shoryuken_options['queue'] = queue_name
|
|
41
|
+
Shoryuken.register_worker(queue_name, short_vt_worker)
|
|
42
|
+
|
|
43
|
+
queue_url = Shoryuken::Client.sqs.get_queue_url(queue_name: queue_name).queue_url
|
|
44
|
+
Shoryuken::Client.sqs.send_message(queue_url: queue_url, message_body: 'hello')
|
|
45
|
+
|
|
46
|
+
poll_queues_until(timeout: 20) { DT[:processed].size >= 1 }
|
|
47
|
+
|
|
48
|
+
assert_equal(
|
|
49
|
+
['hello'],
|
|
50
|
+
DT[:processed].to_a,
|
|
51
|
+
'auto_visibility_timeout must not break processing on a queue with a short visibility timeout'
|
|
52
|
+
)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# This spec tests that the manager's busy-processor accounting stays correct
|
|
4
|
+
# when processor completion handling itself raises.
|
|
5
|
+
#
|
|
6
|
+
# Manager#processor_done makes SQS calls (Shoryuken::Client.queues, #fifo?)
|
|
7
|
+
# and invokes the polling strategy's message_processed callback (a documented
|
|
8
|
+
# extension point) - any of these can raise, e.g. on a transient network
|
|
9
|
+
# error or a bug in a custom strategy.
|
|
10
|
+
#
|
|
11
|
+
# Expected behavior: processor_done runs exactly once per processed message
|
|
12
|
+
# and busy_processors never goes negative, even when processor_done raises.
|
|
13
|
+
#
|
|
14
|
+
# Regression: Manager#assign chained `.then { processor_done }` with
|
|
15
|
+
# `.rescue { processor_done }`, so an exception inside processor_done
|
|
16
|
+
# rejected the then-promise and ran processor_done a SECOND time. The busy
|
|
17
|
+
# counter was decremented twice for one message and drifted negative,
|
|
18
|
+
# inflating `ready` and silently breaking the concurrency limit for the
|
|
19
|
+
# life of the process.
|
|
20
|
+
|
|
21
|
+
require 'timeout'
|
|
22
|
+
|
|
23
|
+
setup_sqs
|
|
24
|
+
|
|
25
|
+
DT.clear
|
|
26
|
+
|
|
27
|
+
queue_name = "#{DT.uuid}.fifo"
|
|
28
|
+
create_fifo_queue(queue_name)
|
|
29
|
+
|
|
30
|
+
# A custom polling strategy whose message_processed callback raises, like a
|
|
31
|
+
# buggy user strategy or one that performs I/O and hits a transient error.
|
|
32
|
+
# Manager#processor_done invokes it for FIFO queues.
|
|
33
|
+
class FlakyCallbackStrategy < Shoryuken::Polling::WeightedRoundRobin
|
|
34
|
+
def message_processed(queue)
|
|
35
|
+
DT[:message_processed_calls] << { queue: queue, time: Time.now }
|
|
36
|
+
raise 'flaky message_processed callback'
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
Shoryuken.add_group('default', 2, polling_strategy: FlakyCallbackStrategy)
|
|
41
|
+
Shoryuken.add_queue(queue_name, 1, 'default')
|
|
42
|
+
|
|
43
|
+
accounting_worker = Class.new do
|
|
44
|
+
include Shoryuken::Worker
|
|
45
|
+
|
|
46
|
+
shoryuken_options auto_delete: true
|
|
47
|
+
|
|
48
|
+
def perform(_sqs_msg, body)
|
|
49
|
+
DT[:processed] << body
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
accounting_worker.get_shoryuken_options['queue'] = queue_name
|
|
54
|
+
Shoryuken.register_worker(queue_name, accounting_worker)
|
|
55
|
+
|
|
56
|
+
# busy_processors is exposed through the public utilization_update event
|
|
57
|
+
Shoryuken.on(:utilization_update) do |opts|
|
|
58
|
+
DT[:utilization] << opts.dup
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
launcher = Shoryuken::Launcher.new
|
|
62
|
+
launcher.start
|
|
63
|
+
|
|
64
|
+
begin
|
|
65
|
+
Shoryuken::Client.queues(queue_name).send_message(message_body: 'hello')
|
|
66
|
+
|
|
67
|
+
# Wait for the message to be processed and the completion path to run
|
|
68
|
+
Timeout.timeout(15) { sleep 0.5 until DT[:message_processed_calls].size >= 1 }
|
|
69
|
+
|
|
70
|
+
# Give the (buggy) rescue path time to run processor_done a second time
|
|
71
|
+
sleep 3
|
|
72
|
+
|
|
73
|
+
assert_equal(['hello'], DT[:processed].to_a, 'Message should have been processed exactly once')
|
|
74
|
+
|
|
75
|
+
assert_equal(
|
|
76
|
+
1,
|
|
77
|
+
DT[:message_processed_calls].size,
|
|
78
|
+
'processor_done must run exactly once per message, even when it raises ' \
|
|
79
|
+
"(message_processed was called #{DT[:message_processed_calls].size} times)"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
negative = DT[:utilization].select { |u| u[:busy_processors].negative? }
|
|
83
|
+
assert(
|
|
84
|
+
negative.empty?,
|
|
85
|
+
'busy_processors must never go negative; a double decrement inflates ready ' \
|
|
86
|
+
"and breaks the concurrency limit (saw: #{negative.first.inspect})"
|
|
87
|
+
)
|
|
88
|
+
ensure
|
|
89
|
+
begin
|
|
90
|
+
Timeout.timeout(10) { launcher.stop }
|
|
91
|
+
rescue Timeout::Error
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# A custom polling strategy can put :max_number_of_messages in the
|
|
4
|
+
# QueueConfiguration options. For a FIFO queue that must never raise the
|
|
5
|
+
# per-receive count above 1: SQS can otherwise return several messages from the
|
|
6
|
+
# same group in one receive, which Shoryuken would hand to separate processor
|
|
7
|
+
# threads and run concurrently / out of order.
|
|
8
|
+
#
|
|
9
|
+
# This drives a real launcher with a strategy that asks for 10 messages per
|
|
10
|
+
# receive and asserts, end-to-end, that the fetcher still caps the FIFO request
|
|
11
|
+
# to 1 (and that the messages are processed in order). The assertion on the
|
|
12
|
+
# requested count is deterministic regardless of how the SQS backend chooses to
|
|
13
|
+
# respond.
|
|
14
|
+
|
|
15
|
+
require 'delegate'
|
|
16
|
+
|
|
17
|
+
setup_sqs
|
|
18
|
+
|
|
19
|
+
# Records the max_number_of_messages Shoryuken actually asks SQS for, then
|
|
20
|
+
# forwards every call unchanged to the real client.
|
|
21
|
+
class RecordingSqsClient < SimpleDelegator
|
|
22
|
+
def receive_message(params)
|
|
23
|
+
DT[:receive_max] << params[:max_number_of_messages]
|
|
24
|
+
__getobj__.receive_message(params)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
Shoryuken::Client.sqs = RecordingSqsClient.new(Shoryuken::Client.sqs)
|
|
29
|
+
|
|
30
|
+
# Strategy that requests 10 messages per receive via the queue options - the
|
|
31
|
+
# value the FIFO guard must clamp back down to 1.
|
|
32
|
+
class MaxOverridePollingStrategy < Shoryuken::Polling::BaseStrategy
|
|
33
|
+
def initialize(queues, _delay = nil)
|
|
34
|
+
@queue = queues.first
|
|
35
|
+
@paused_until = Time.at(0)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def next_queue
|
|
39
|
+
return nil if Time.now < @paused_until
|
|
40
|
+
|
|
41
|
+
Shoryuken::Polling::QueueConfiguration.new(@queue, max_number_of_messages: 10)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Pause briefly on an empty poll so the dispatch loop doesn't busy-spin once
|
|
45
|
+
# the queue is drained.
|
|
46
|
+
def messages_found(_queue, count)
|
|
47
|
+
@paused_until = Time.now + 0.5 if count.zero?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def active_queues
|
|
51
|
+
[[@queue, 1]]
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
queue_name = "#{DT.uuid}.fifo"
|
|
56
|
+
create_fifo_queue(queue_name)
|
|
57
|
+
Shoryuken.add_group('default', 1, polling_strategy: MaxOverridePollingStrategy)
|
|
58
|
+
Shoryuken.add_queue(queue_name, 1, 'default')
|
|
59
|
+
|
|
60
|
+
worker_class = Class.new do
|
|
61
|
+
include Shoryuken::Worker
|
|
62
|
+
|
|
63
|
+
def perform(_sqs_msg, body)
|
|
64
|
+
DT[:processed] << body
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
worker_class.get_shoryuken_options['queue'] = queue_name
|
|
68
|
+
worker_class.get_shoryuken_options['auto_delete'] = true
|
|
69
|
+
worker_class.get_shoryuken_options['batch'] = false
|
|
70
|
+
Shoryuken.register_worker(queue_name, worker_class)
|
|
71
|
+
|
|
72
|
+
queue_url = Shoryuken::Client.sqs.get_queue_url(queue_name: queue_name).queue_url
|
|
73
|
+
|
|
74
|
+
3.times do |i|
|
|
75
|
+
Shoryuken::Client.sqs.send_message(
|
|
76
|
+
queue_url: queue_url,
|
|
77
|
+
message_body: "msg-#{i}",
|
|
78
|
+
message_group_id: 'group-a',
|
|
79
|
+
message_deduplication_id: SecureRandom.uuid
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
sleep 1
|
|
84
|
+
|
|
85
|
+
poll_queues_until { DT[:processed].size >= 3 }
|
|
86
|
+
|
|
87
|
+
# Messages were processed, in order...
|
|
88
|
+
assert_equal(%w[msg-0 msg-1 msg-2], DT[:processed])
|
|
89
|
+
|
|
90
|
+
# ...and every FIFO receive requested at most one message, even though the
|
|
91
|
+
# strategy asked for ten - the one-at-a-time guard stays authoritative.
|
|
92
|
+
assert(DT[:receive_max].any?, 'expected at least one receive_message call')
|
|
93
|
+
assert(
|
|
94
|
+
DT[:receive_max].all? { |max| max == 1 },
|
|
95
|
+
"FIFO receive should cap max_number_of_messages at 1, saw: #{DT[:receive_max].uniq.inspect}"
|
|
96
|
+
)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# This spec tests that a second graceful stop returns instead of deadlocking.
|
|
4
|
+
#
|
|
5
|
+
# Operationally this is the TSTP -> USR1 sequence: Runner handles TSTP by
|
|
6
|
+
# calling Launcher#stop ("stop accepting new work") and a later USR1 calls
|
|
7
|
+
# Launcher#stop again for the final soft shutdown.
|
|
8
|
+
#
|
|
9
|
+
# Regression: Manager#await_dispatching_in_progress popped a Queue that
|
|
10
|
+
# received exactly one signal when the dispatch loop observed the stop flag.
|
|
11
|
+
# A second Launcher#stop popped an empty queue and blocked forever, leaving
|
|
12
|
+
# the process stuck in the signal loop where even TERM/INT were no longer
|
|
13
|
+
# processed - only SIGKILL could stop it.
|
|
14
|
+
|
|
15
|
+
require 'timeout'
|
|
16
|
+
|
|
17
|
+
setup_sqs
|
|
18
|
+
|
|
19
|
+
DT.clear
|
|
20
|
+
|
|
21
|
+
queue_name = DT.queue
|
|
22
|
+
create_test_queue(queue_name)
|
|
23
|
+
Shoryuken.add_group('default', 1)
|
|
24
|
+
Shoryuken.add_queue(queue_name, 1, 'default')
|
|
25
|
+
|
|
26
|
+
stop_worker = Class.new do
|
|
27
|
+
include Shoryuken::Worker
|
|
28
|
+
|
|
29
|
+
shoryuken_options auto_delete: true
|
|
30
|
+
|
|
31
|
+
def perform(_sqs_msg, body)
|
|
32
|
+
DT[:processed] << body
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
stop_worker.get_shoryuken_options['queue'] = queue_name
|
|
37
|
+
Shoryuken.register_worker(queue_name, stop_worker)
|
|
38
|
+
|
|
39
|
+
queue_url = Shoryuken::Client.sqs.get_queue_url(queue_name: queue_name).queue_url
|
|
40
|
+
|
|
41
|
+
launcher = Shoryuken::Launcher.new
|
|
42
|
+
launcher.start
|
|
43
|
+
|
|
44
|
+
begin
|
|
45
|
+
# Prove the launcher is fully up and dispatching before stopping it
|
|
46
|
+
Shoryuken::Client.sqs.send_message(queue_url: queue_url, message_body: 'before stop')
|
|
47
|
+
Timeout.timeout(10) { sleep 0.5 until DT[:processed].size >= 1 }
|
|
48
|
+
|
|
49
|
+
# First graceful stop (TSTP: "stop accepting new work")
|
|
50
|
+
Timeout.timeout(10) { launcher.stop }
|
|
51
|
+
|
|
52
|
+
# Second graceful stop (USR1 after TSTP: final soft shutdown).
|
|
53
|
+
# This must return promptly instead of blocking forever.
|
|
54
|
+
begin
|
|
55
|
+
Timeout.timeout(10) { launcher.stop }
|
|
56
|
+
rescue Timeout::Error
|
|
57
|
+
raise IntegrationsHelper::TestFailure,
|
|
58
|
+
'Second Launcher#stop deadlocked: await_dispatching_in_progress blocked on an empty signal queue ' \
|
|
59
|
+
'(TSTP -> USR1 leaves the process unkillable except SIGKILL)'
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
assert_equal(['before stop'], DT[:processed].to_a, 'Message sent before shutdown should have been processed')
|
|
63
|
+
ensure
|
|
64
|
+
# Best-effort cleanup so a failure above never leaves a live launcher behind.
|
|
65
|
+
# On the happy path this is also a third stop, exercising idempotent shutdown.
|
|
66
|
+
begin
|
|
67
|
+
Timeout.timeout(10) { launcher.stop }
|
|
68
|
+
rescue Timeout::Error
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# This spec tests that a fatal dispatch error does not hard-kill the host
|
|
4
|
+
# process when Shoryuken runs embedded (Launcher used directly, without the
|
|
5
|
+
# CLI Runner).
|
|
6
|
+
#
|
|
7
|
+
# When the dispatch loop hits an unrecoverable error (e.g. SQS still failing
|
|
8
|
+
# after the fetcher exhausts its retries), Manager#handle_dispatch_error used
|
|
9
|
+
# to send Process.kill('USR1', Process.pid) unconditionally. The CLI Runner
|
|
10
|
+
# traps USR1 and turns it into a graceful shutdown, but an embedded host has
|
|
11
|
+
# USR1's default disposition, which terminates the whole process - killing any
|
|
12
|
+
# in-flight workers.
|
|
13
|
+
#
|
|
14
|
+
# Expected behavior when embedded: the failing manager stops itself and
|
|
15
|
+
# Launcher#healthy? reports the failure; no process-killing signal is sent.
|
|
16
|
+
#
|
|
17
|
+
# Regression: embedded mode received an untrapped USR1 and the process died.
|
|
18
|
+
|
|
19
|
+
require 'timeout'
|
|
20
|
+
|
|
21
|
+
setup_sqs
|
|
22
|
+
|
|
23
|
+
DT.clear
|
|
24
|
+
|
|
25
|
+
queue_name = DT.queue
|
|
26
|
+
create_test_queue(queue_name)
|
|
27
|
+
Shoryuken.add_group('default', 1)
|
|
28
|
+
Shoryuken.add_queue(queue_name, 1, 'default')
|
|
29
|
+
|
|
30
|
+
embedded_worker = Class.new do
|
|
31
|
+
include Shoryuken::Worker
|
|
32
|
+
|
|
33
|
+
shoryuken_options auto_delete: true
|
|
34
|
+
|
|
35
|
+
def perform(_sqs_msg, _body); end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
embedded_worker.get_shoryuken_options['queue'] = queue_name
|
|
39
|
+
Shoryuken.register_worker(queue_name, embedded_worker)
|
|
40
|
+
|
|
41
|
+
# Force every fetch to fail so the dispatch loop reaches handle_dispatch_error.
|
|
42
|
+
# Prepend (rather than redefine) to avoid method-redefinition warnings.
|
|
43
|
+
failing_fetch = Module.new do
|
|
44
|
+
def fetch(*)
|
|
45
|
+
raise 'simulated persistent fetch failure'
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
Shoryuken::Fetcher.prepend(failing_fetch)
|
|
49
|
+
|
|
50
|
+
# Safely observe whether the process is signalled with USR1. A real embedded
|
|
51
|
+
# host has the default (lethal) disposition; here we trap it only so the test
|
|
52
|
+
# runner survives long enough to assert that the signal is not sent. The trap
|
|
53
|
+
# runs on the main thread, so a plain boolean assignment (no lock, no
|
|
54
|
+
# allocation) is trap-safe and visible to the assertions below.
|
|
55
|
+
usr1_received = false
|
|
56
|
+
previous_handler = Signal.trap('USR1') { usr1_received = true }
|
|
57
|
+
|
|
58
|
+
launcher = Shoryuken::Launcher.new
|
|
59
|
+
launcher.start
|
|
60
|
+
|
|
61
|
+
begin
|
|
62
|
+
# The failing dispatch should drive the manager - and therefore the launcher -
|
|
63
|
+
# unhealthy.
|
|
64
|
+
Timeout.timeout(15) { sleep 0.2 while launcher.healthy? }
|
|
65
|
+
|
|
66
|
+
# Give any (buggy) USR1 a chance to be delivered and handled.
|
|
67
|
+
sleep 1
|
|
68
|
+
|
|
69
|
+
refute(
|
|
70
|
+
usr1_received,
|
|
71
|
+
'Embedded mode must not send USR1 on a fatal dispatch error: with no Runner ' \
|
|
72
|
+
'trapping it, the default disposition terminates the host process and its ' \
|
|
73
|
+
'in-flight workers'
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
assert(!launcher.healthy?, 'Launcher should report unhealthy after a fatal dispatch error')
|
|
77
|
+
ensure
|
|
78
|
+
Signal.trap('USR1', previous_handler || 'DEFAULT')
|
|
79
|
+
|
|
80
|
+
begin
|
|
81
|
+
Timeout.timeout(10) { launcher.stop }
|
|
82
|
+
rescue Timeout::Error
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# This spec tests that stopping the launcher does not destroy the process-wide
|
|
4
|
+
# Concurrent.global_io_executor.
|
|
5
|
+
#
|
|
6
|
+
# When no launcher_executor is configured, Launcher#executor used to fall back
|
|
7
|
+
# to Concurrent.global_io_executor - and Launcher#stop / #stop! call
|
|
8
|
+
# executor.shutdown (and kill). That pool is a process-global singleton shared
|
|
9
|
+
# by anything using concurrent-ruby's :io pool (including Shoryuken's own
|
|
10
|
+
# ShoryukenConcurrentSendAdapter), so shutting it down breaks unrelated work and
|
|
11
|
+
# prevents a fresh launcher from being started in the same process.
|
|
12
|
+
#
|
|
13
|
+
# Expected behavior: the launcher owns a dedicated executor, so stopping it
|
|
14
|
+
# leaves the global IO executor untouched.
|
|
15
|
+
#
|
|
16
|
+
# Regression: stopping the launcher shut down/killed the global IO executor.
|
|
17
|
+
|
|
18
|
+
require 'timeout'
|
|
19
|
+
|
|
20
|
+
setup_sqs
|
|
21
|
+
|
|
22
|
+
# Exercise the real default executor path. The integrations helper injects a
|
|
23
|
+
# dedicated pool via launcher_executor; production CLI/embedded use leaves it
|
|
24
|
+
# nil, which is exactly the path that fell back to the global pool.
|
|
25
|
+
Shoryuken.define_singleton_method(:launcher_executor) { nil }
|
|
26
|
+
|
|
27
|
+
DT.clear
|
|
28
|
+
|
|
29
|
+
queue_name = DT.queue
|
|
30
|
+
create_test_queue(queue_name)
|
|
31
|
+
Shoryuken.add_group('default', 1)
|
|
32
|
+
Shoryuken.add_queue(queue_name, 1, 'default')
|
|
33
|
+
|
|
34
|
+
worker = Class.new do
|
|
35
|
+
include Shoryuken::Worker
|
|
36
|
+
|
|
37
|
+
shoryuken_options auto_delete: true
|
|
38
|
+
|
|
39
|
+
def perform(_sqs_msg, body)
|
|
40
|
+
DT[:processed] << body
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
worker.get_shoryuken_options['queue'] = queue_name
|
|
45
|
+
Shoryuken.register_worker(queue_name, worker)
|
|
46
|
+
|
|
47
|
+
queue_url = Shoryuken::Client.sqs.get_queue_url(queue_name: queue_name).queue_url
|
|
48
|
+
|
|
49
|
+
launcher = Shoryuken::Launcher.new
|
|
50
|
+
launcher.start
|
|
51
|
+
|
|
52
|
+
Shoryuken::Client.sqs.send_message(queue_url: queue_url, message_body: 'hello')
|
|
53
|
+
Timeout.timeout(15) { sleep 0.2 until DT[:processed].size >= 1 }
|
|
54
|
+
|
|
55
|
+
launcher.stop!
|
|
56
|
+
|
|
57
|
+
# The process-global IO executor must still be running after the launcher stops.
|
|
58
|
+
assert(
|
|
59
|
+
Concurrent.global_io_executor.running?,
|
|
60
|
+
'Launcher must not shut down the process-global IO executor on stop; ' \
|
|
61
|
+
'other code (including ShoryukenConcurrentSendAdapter) relies on it'
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# ...and it must still actually run work scheduled on it.
|
|
65
|
+
result =
|
|
66
|
+
begin
|
|
67
|
+
Concurrent::Promises.future_on(Concurrent.global_io_executor) { 42 }.value(5)
|
|
68
|
+
rescue Concurrent::RejectedExecutionError
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
assert_equal(
|
|
73
|
+
42, result,
|
|
74
|
+
'A task scheduled on the global IO executor after the launcher stops should still run; ' \
|
|
75
|
+
'the launcher destroyed the shared pool'
|
|
76
|
+
)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# This spec tests that a graceful stop is bounded by the configured timeout and
|
|
4
|
+
# does not block forever on a worker that refuses to finish.
|
|
5
|
+
#
|
|
6
|
+
# Launcher#stop (the soft shutdown behind USR1/TSTP) waits for in-flight workers
|
|
7
|
+
# to finish so their messages are not redelivered. It used to call
|
|
8
|
+
# executor.wait_for_termination with no argument - an unbounded wait - so a
|
|
9
|
+
# single hung worker would block the shutdown forever (and, because the signal
|
|
10
|
+
# loop is stuck, leave the process killable only with SIGKILL).
|
|
11
|
+
#
|
|
12
|
+
# Expected behavior: workers get up to Shoryuken.options[:timeout] seconds to
|
|
13
|
+
# finish, after which the executor is killed and stop returns. Launcher#stop!
|
|
14
|
+
# already did this; Launcher#stop should too.
|
|
15
|
+
#
|
|
16
|
+
# Regression: a hung worker hung Launcher#stop indefinitely.
|
|
17
|
+
|
|
18
|
+
require 'timeout'
|
|
19
|
+
|
|
20
|
+
setup_sqs
|
|
21
|
+
|
|
22
|
+
DT.clear
|
|
23
|
+
|
|
24
|
+
# Short grace period so the test is fast; the worker hangs well past it.
|
|
25
|
+
Shoryuken.options[:timeout] = 2
|
|
26
|
+
|
|
27
|
+
queue_name = DT.queue
|
|
28
|
+
create_test_queue(queue_name)
|
|
29
|
+
Shoryuken.add_group('default', 1)
|
|
30
|
+
Shoryuken.add_queue(queue_name, 1, 'default')
|
|
31
|
+
|
|
32
|
+
hanging_worker = Class.new do
|
|
33
|
+
include Shoryuken::Worker
|
|
34
|
+
|
|
35
|
+
shoryuken_options auto_delete: true
|
|
36
|
+
|
|
37
|
+
def perform(_sqs_msg, _body)
|
|
38
|
+
DT[:started] << true
|
|
39
|
+
# Hang far longer than the grace period to simulate a stuck worker.
|
|
40
|
+
sleep 60
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
hanging_worker.get_shoryuken_options['queue'] = queue_name
|
|
45
|
+
Shoryuken.register_worker(queue_name, hanging_worker)
|
|
46
|
+
|
|
47
|
+
queue_url = Shoryuken::Client.sqs.get_queue_url(queue_name: queue_name).queue_url
|
|
48
|
+
|
|
49
|
+
launcher = Shoryuken::Launcher.new
|
|
50
|
+
launcher.start
|
|
51
|
+
|
|
52
|
+
Shoryuken::Client.sqs.send_message(queue_url: queue_url, message_body: 'hang')
|
|
53
|
+
|
|
54
|
+
# Make sure the worker is genuinely in-flight before we stop.
|
|
55
|
+
Timeout.timeout(15) { sleep 0.2 until DT[:started].size >= 1 }
|
|
56
|
+
|
|
57
|
+
# Graceful stop must return within roughly the grace period, not block for the
|
|
58
|
+
# full 60s the worker sleeps. Allow generous margin over the 2s timeout.
|
|
59
|
+
started_at = Time.now
|
|
60
|
+
begin
|
|
61
|
+
Timeout.timeout(20) { launcher.stop }
|
|
62
|
+
rescue Timeout::Error
|
|
63
|
+
raise IntegrationsHelper::TestFailure,
|
|
64
|
+
'Launcher#stop blocked past the configured timeout on a hung worker: ' \
|
|
65
|
+
'a graceful stop must bound executor.wait_for_termination and kill the ' \
|
|
66
|
+
'executor when exceeded (an unbounded wait hangs USR1/TSTP shutdown forever)'
|
|
67
|
+
end
|
|
68
|
+
elapsed = Time.now - started_at
|
|
69
|
+
|
|
70
|
+
assert(
|
|
71
|
+
elapsed < 15,
|
|
72
|
+
"Graceful stop should return shortly after the #{Shoryuken.options[:timeout]}s timeout, " \
|
|
73
|
+
"took #{elapsed.round(1)}s"
|
|
74
|
+
)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# This spec exercises Queue#delete_messages against a real partial batch-delete
|
|
4
|
+
# failure (one valid receipt handle plus one invalid one).
|
|
5
|
+
#
|
|
6
|
+
# It confirms the end-to-end contract the NonRetryableException and AutoDelete
|
|
7
|
+
# middlewares rely on: delete_messages returns a plain boolean true when any
|
|
8
|
+
# entry fails, and a partial failure does not abort the rest of the batch (the
|
|
9
|
+
# valid entry is still deleted).
|
|
10
|
+
#
|
|
11
|
+
# Note: the underlying bug (only the first failure was logged, and the truthy
|
|
12
|
+
# return relied on Logger#error returning true) is only observable with a logger
|
|
13
|
+
# that returns falsey, so it is reproduced in the unit specs. With the default
|
|
14
|
+
# logger this path returns true either way - this spec guards the real-SQS
|
|
15
|
+
# behavior of the rewritten method.
|
|
16
|
+
|
|
17
|
+
require 'timeout'
|
|
18
|
+
|
|
19
|
+
setup_sqs
|
|
20
|
+
|
|
21
|
+
DT.clear
|
|
22
|
+
|
|
23
|
+
queue_name = DT.queue
|
|
24
|
+
create_test_queue(queue_name)
|
|
25
|
+
queue_url = Shoryuken::Client.sqs.get_queue_url(queue_name: queue_name).queue_url
|
|
26
|
+
|
|
27
|
+
# Send and receive a message so we hold a valid receipt handle.
|
|
28
|
+
Shoryuken::Client.sqs.send_message(queue_url: queue_url, message_body: 'keep')
|
|
29
|
+
|
|
30
|
+
received = []
|
|
31
|
+
Timeout.timeout(15) do
|
|
32
|
+
loop do
|
|
33
|
+
received = Shoryuken::Client.sqs.receive_message(
|
|
34
|
+
queue_url: queue_url, max_number_of_messages: 1, wait_time_seconds: 1
|
|
35
|
+
).messages
|
|
36
|
+
break if received.any?
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
valid_handle = received.first.receipt_handle
|
|
40
|
+
|
|
41
|
+
# Batch-delete one valid handle and one bogus handle: a partial failure.
|
|
42
|
+
queue = Shoryuken::Client.queues(queue_name)
|
|
43
|
+
result = queue.delete_messages(
|
|
44
|
+
entries: [
|
|
45
|
+
{ id: '0', receipt_handle: valid_handle },
|
|
46
|
+
{ id: '1', receipt_handle: 'bogus-receipt-handle' }
|
|
47
|
+
]
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
assert_equal(true, result, 'delete_messages should return true when any entry fails to delete')
|
|
51
|
+
|
|
52
|
+
# The valid entry was still deleted; the partial failure did not abort the batch.
|
|
53
|
+
remaining = nil
|
|
54
|
+
Timeout.timeout(10) do
|
|
55
|
+
loop do
|
|
56
|
+
attrs = Shoryuken::Client.sqs.get_queue_attributes(
|
|
57
|
+
queue_url: queue_url,
|
|
58
|
+
attribute_names: %w[ApproximateNumberOfMessages ApproximateNumberOfMessagesNotVisible]
|
|
59
|
+
).attributes
|
|
60
|
+
remaining = attrs['ApproximateNumberOfMessages'].to_i + attrs['ApproximateNumberOfMessagesNotVisible'].to_i
|
|
61
|
+
break if remaining.zero?
|
|
62
|
+
|
|
63
|
+
sleep 0.3
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
assert_equal(0, remaining, 'the successfully-deleted message should be gone after the partial failure')
|
|
@@ -144,6 +144,6 @@ begin
|
|
|
144
144
|
assert_equal(1, success_attempts.size, 'Successful message should only be attempted once')
|
|
145
145
|
assert_equal(2, DT[:successful_processing].size, 'Both successful messages should be processed')
|
|
146
146
|
ensure
|
|
147
|
-
launcher.stop
|
|
147
|
+
Timeout.timeout(30) { launcher.stop }
|
|
148
148
|
end
|
|
149
149
|
|