shoryuken 7.0.2 → 7.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/push.yml +3 -3
  3. data/.github/workflows/specs.yml +32 -5
  4. data/.github/workflows/verify-action-pins.yml +1 -1
  5. data/.ruby-version +1 -1
  6. data/CHANGELOG.md +107 -0
  7. data/bin/cli/sqs.rb +7 -0
  8. data/bin/integrations +52 -34
  9. data/lib/active_job/queue_adapters/shoryuken_adapter.rb +3 -1
  10. data/lib/shoryuken/active_job/current_attributes.rb +35 -7
  11. data/lib/shoryuken/fetcher.rb +7 -1
  12. data/lib/shoryuken/helpers/timer_task.rb +19 -2
  13. data/lib/shoryuken/launcher.rb +21 -5
  14. data/lib/shoryuken/manager.rb +38 -5
  15. data/lib/shoryuken/middleware/server/auto_extend_visibility.rb +32 -2
  16. data/lib/shoryuken/middleware/server/exponential_backoff_retry.rb +8 -3
  17. data/lib/shoryuken/middleware/server/non_retryable_exception.rb +17 -8
  18. data/lib/shoryuken/options.rb +12 -1
  19. data/lib/shoryuken/polling/strict_priority.rb +26 -14
  20. data/lib/shoryuken/polling/weighted_round_robin.rb +41 -27
  21. data/lib/shoryuken/queue.rb +8 -1
  22. data/lib/shoryuken/util.rb +4 -1
  23. data/lib/shoryuken/version.rb +1 -1
  24. data/lib/shoryuken/worker.rb +5 -1
  25. data/lib/shoryuken.rb +2 -0
  26. data/renovate.json +16 -2
  27. data/spec/integration/active_job/current_attributes/cross_job_reset_spec.rb +47 -0
  28. data/spec/integration/active_job/current_attributes/incremental_persist_spec.rb +76 -0
  29. data/spec/integration/active_job/fifo_dedup_opt_out/fifo_dedup_opt_out_spec.rb +67 -0
  30. data/spec/integration/auto_extend_visibility/short_visibility_timeout_spec.rb +52 -0
  31. data/spec/integration/concurrent_processing/processor_accounting_spec.rb +94 -0
  32. data/spec/integration/fifo_ordering/fifo_max_messages_cap_spec.rb +96 -0
  33. data/spec/integration/launcher/double_graceful_stop_spec.rb +71 -0
  34. data/spec/integration/launcher/embedded_dispatch_error_spec.rb +85 -0
  35. data/spec/integration/launcher/global_executor_preserved_spec.rb +76 -0
  36. data/spec/integration/launcher/graceful_stop_timeout_spec.rb +74 -0
  37. data/spec/integration/message_operations/partial_batch_delete_spec.rb +67 -0
  38. data/spec/integration/non_retryable_exception/non_retryable_exception_spec.rb +1 -1
  39. data/spec/integration/non_retryable_exception/with_retry_intervals_spec.rb +115 -0
  40. data/spec/integrations_helper.rb +10 -9
  41. data/spec/lib/shoryuken/fetcher_spec.rb +13 -0
  42. data/spec/lib/shoryuken/helpers/timer_task_spec.rb +24 -0
  43. data/spec/lib/shoryuken/launcher_spec.rb +38 -0
  44. data/spec/lib/shoryuken/manager_spec.rb +99 -0
  45. data/spec/lib/shoryuken/middleware/server/auto_extend_visibility_spec.rb +35 -0
  46. data/spec/lib/shoryuken/middleware/server/exponential_backoff_retry_spec.rb +56 -0
  47. data/spec/lib/shoryuken/polling/strict_priority_spec.rb +25 -0
  48. data/spec/lib/shoryuken/polling/weighted_round_robin_spec.rb +50 -0
  49. data/spec/lib/shoryuken/queue_spec.rb +37 -0
  50. data/spec/lib/shoryuken/util_spec.rb +26 -0
  51. data/spec/shared_examples_for_active_job.rb +18 -0
  52. data/spec/spec_helper.rb +26 -7
  53. metadata +26 -2
@@ -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