shoryuken 7.0.1 → 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 (98) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/push.yml +3 -3
  3. data/.github/workflows/specs.yml +37 -10
  4. data/.github/workflows/verify-action-pins.yml +1 -1
  5. data/.gitignore +0 -1
  6. data/.ruby-version +1 -1
  7. data/CHANGELOG.md +120 -0
  8. data/README.md +1 -1
  9. data/bin/{clean_localstack → clean_sqs} +4 -4
  10. data/bin/cli/sqs.rb +7 -0
  11. data/bin/integrations +52 -34
  12. data/docker-compose.yml +4 -20
  13. data/lib/active_job/queue_adapters/shoryuken_adapter.rb +3 -1
  14. data/lib/shoryuken/active_job/current_attributes.rb +35 -7
  15. data/lib/shoryuken/environment_loader.rb +1 -1
  16. data/lib/shoryuken/fetcher.rb +7 -1
  17. data/lib/shoryuken/helpers/timer_task.rb +19 -2
  18. data/lib/shoryuken/launcher.rb +21 -5
  19. data/lib/shoryuken/manager.rb +38 -5
  20. data/lib/shoryuken/middleware/server/auto_extend_visibility.rb +32 -2
  21. data/lib/shoryuken/middleware/server/exponential_backoff_retry.rb +8 -3
  22. data/lib/shoryuken/middleware/server/non_retryable_exception.rb +17 -8
  23. data/lib/shoryuken/options.rb +22 -3
  24. data/lib/shoryuken/polling/strict_priority.rb +26 -14
  25. data/lib/shoryuken/polling/weighted_round_robin.rb +41 -27
  26. data/lib/shoryuken/queue.rb +8 -1
  27. data/lib/shoryuken/util.rb +4 -1
  28. data/lib/shoryuken/version.rb +1 -1
  29. data/lib/shoryuken/worker.rb +5 -1
  30. data/lib/shoryuken.rb +2 -0
  31. data/renovate.json +26 -2
  32. data/spec/integration/active_job/bulk_enqueue/bulk_enqueue_spec.rb +1 -1
  33. data/spec/integration/active_job/current_attributes/bulk_enqueue_spec.rb +1 -1
  34. data/spec/integration/active_job/current_attributes/complex_types_spec.rb +1 -1
  35. data/spec/integration/active_job/current_attributes/cross_job_reset_spec.rb +47 -0
  36. data/spec/integration/active_job/current_attributes/empty_context_spec.rb +1 -1
  37. data/spec/integration/active_job/current_attributes/full_context_spec.rb +1 -1
  38. data/spec/integration/active_job/current_attributes/incremental_persist_spec.rb +76 -0
  39. data/spec/integration/active_job/current_attributes/partial_context_spec.rb +1 -1
  40. data/spec/integration/active_job/custom_attributes/number_attributes_spec.rb +1 -1
  41. data/spec/integration/active_job/custom_attributes/string_attributes_spec.rb +1 -1
  42. data/spec/integration/active_job/fifo_dedup_opt_out/fifo_dedup_opt_out_spec.rb +67 -0
  43. data/spec/integration/active_job/keyword_arguments/keyword_arguments_spec.rb +1 -1
  44. data/spec/integration/active_job/retry/discard_on_spec.rb +1 -1
  45. data/spec/integration/active_job/retry/retry_on_spec.rb +1 -1
  46. data/spec/integration/active_job/roundtrip/roundtrip_spec.rb +2 -2
  47. data/spec/integration/active_job/scheduled/scheduled_spec.rb +1 -1
  48. data/spec/integration/active_record_middleware/active_record_middleware_spec.rb +1 -1
  49. data/spec/integration/auto_delete/auto_delete_spec.rb +1 -1
  50. data/spec/integration/auto_extend_visibility/auto_extend_visibility_spec.rb +1 -1
  51. data/spec/integration/auto_extend_visibility/short_visibility_timeout_spec.rb +52 -0
  52. data/spec/integration/aws_config/aws_config_spec.rb +3 -3
  53. data/spec/integration/batch_processing/batch_processing_spec.rb +1 -1
  54. data/spec/integration/body_parser/json_parser_spec.rb +1 -1
  55. data/spec/integration/body_parser/proc_parser_spec.rb +1 -1
  56. data/spec/integration/body_parser/text_parser_spec.rb +1 -1
  57. data/spec/integration/concurrent_processing/concurrent_processing_spec.rb +1 -1
  58. data/spec/integration/concurrent_processing/processor_accounting_spec.rb +94 -0
  59. data/spec/integration/custom_group_polling_strategy/custom_group_polling_strategy_spec.rb +87 -0
  60. data/spec/integration/dead_letter_queue/dead_letter_queue_spec.rb +1 -1
  61. data/spec/integration/exception_handlers/exception_handlers_spec.rb +1 -1
  62. data/spec/integration/exponential_backoff/exponential_backoff_spec.rb +1 -1
  63. data/spec/integration/fifo_ordering/fifo_max_messages_cap_spec.rb +96 -0
  64. data/spec/integration/fifo_ordering/fifo_ordering_spec.rb +1 -1
  65. data/spec/integration/large_payloads/large_payloads_spec.rb +1 -1
  66. data/spec/integration/launcher/double_graceful_stop_spec.rb +71 -0
  67. data/spec/integration/launcher/embedded_dispatch_error_spec.rb +85 -0
  68. data/spec/integration/launcher/global_executor_preserved_spec.rb +76 -0
  69. data/spec/integration/launcher/graceful_stop_timeout_spec.rb +74 -0
  70. data/spec/integration/launcher/launcher_spec.rb +1 -1
  71. data/spec/integration/message_attributes/message_attributes_spec.rb +1 -1
  72. data/spec/integration/message_operations/message_operations_spec.rb +1 -1
  73. data/spec/integration/message_operations/partial_batch_delete_spec.rb +67 -0
  74. data/spec/integration/non_retryable_exception/non_retryable_exception_spec.rb +2 -2
  75. data/spec/integration/non_retryable_exception/with_retry_intervals_spec.rb +115 -0
  76. data/spec/integration/polling_strategies/polling_strategies_spec.rb +1 -1
  77. data/spec/integration/queue_operations/queue_operations_spec.rb +1 -1
  78. data/spec/integration/retry_behavior/retry_behavior_spec.rb +1 -1
  79. data/spec/integration/strict_priority_polling/strict_priority_polling_spec.rb +1 -1
  80. data/spec/integration/visibility_timeout/visibility_timeout_spec.rb +1 -1
  81. data/spec/integration/worker_enqueueing/worker_enqueueing_spec.rb +1 -1
  82. data/spec/integration/worker_groups/worker_groups_spec.rb +1 -1
  83. data/spec/integration/worker_lifecycle/worker_lifecycle_spec.rb +1 -1
  84. data/spec/integrations_helper.rb +13 -12
  85. data/spec/lib/shoryuken/fetcher_spec.rb +13 -0
  86. data/spec/lib/shoryuken/helpers/timer_task_spec.rb +24 -0
  87. data/spec/lib/shoryuken/launcher_spec.rb +38 -0
  88. data/spec/lib/shoryuken/manager_spec.rb +99 -0
  89. data/spec/lib/shoryuken/middleware/server/auto_extend_visibility_spec.rb +35 -0
  90. data/spec/lib/shoryuken/middleware/server/exponential_backoff_retry_spec.rb +56 -0
  91. data/spec/lib/shoryuken/options_spec.rb +43 -0
  92. data/spec/lib/shoryuken/polling/strict_priority_spec.rb +25 -0
  93. data/spec/lib/shoryuken/polling/weighted_round_robin_spec.rb +50 -0
  94. data/spec/lib/shoryuken/queue_spec.rb +37 -0
  95. data/spec/lib/shoryuken/util_spec.rb +26 -0
  96. data/spec/shared_examples_for_active_job.rb +18 -0
  97. data/spec/spec_helper.rb +26 -7
  98. metadata +29 -3
@@ -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
+ )
@@ -3,7 +3,7 @@
3
3
  # This spec tests FIFO queue ordering guarantees including message ordering
4
4
  # within the same message group.
5
5
 
6
- setup_localstack
6
+ setup_sqs
7
7
 
8
8
  queue_name = "#{DT.uuid}.fifo"
9
9
  create_fifo_queue(queue_name)
@@ -2,7 +2,7 @@
2
2
 
3
3
  # This spec tests large payload handling including payloads near the 256KB SQS limit.
4
4
 
5
- setup_localstack
5
+ setup_sqs
6
6
 
7
7
  queue_name = DT.queue
8
8
  create_test_queue(queue_name)
@@ -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
+ )
@@ -5,7 +5,7 @@
5
5
 
6
6
  require 'concurrent'
7
7
 
8
- setup_localstack
8
+ setup_sqs
9
9
 
10
10
  # Use atomic counter for thread-safe message counting
11
11
  message_counter = Concurrent::AtomicFixnum.new(0)
@@ -4,7 +4,7 @@
4
4
  # attribute types, system attributes (ApproximateReceiveCount, SentTimestamp),
5
5
  # and custom type suffixes.
6
6
 
7
- setup_localstack
7
+ setup_sqs
8
8
 
9
9
  queue_name = DT.queue
10
10
  create_test_queue(queue_name)
@@ -4,7 +4,7 @@
4
4
  # - sqs_msg.delete - manually delete a message
5
5
  # - sqs_msg.change_visibility - change visibility with options
6
6
 
7
- setup_localstack
7
+ setup_sqs
8
8
 
9
9
  queue_name = DT.queue
10
10
  create_test_queue(queue_name, attributes: { 'VisibilityTimeout' => '5' })
@@ -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')
@@ -6,7 +6,7 @@
6
6
 
7
7
  require 'timeout'
8
8
 
9
- setup_localstack
9
+ setup_sqs
10
10
 
11
11
  # Reset DT state to prevent data leakage from previous tests
12
12
  DT.clear
@@ -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
 
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This spec tests the interplay between non_retryable_exceptions and
4
+ # retry_intervals (exponential backoff) when both are configured on the
5
+ # same worker.
6
+ #
7
+ # Expected behavior: non_retryable_exceptions takes precedence - a message
8
+ # raising a non-retryable exception must be deleted immediately and never
9
+ # retried, while other exceptions still go through the backoff schedule.
10
+ #
11
+ # Regression: ExponentialBackoffRetry sits inside NonRetryableException in
12
+ # the default middleware chain and used to swallow every exception after
13
+ # scheduling a retry, so NonRetryableException never saw non-retryable
14
+ # errors and the message was retried (with the last interval repeated)
15
+ # instead of being deleted.
16
+
17
+ require 'timeout'
18
+
19
+ setup_sqs
20
+
21
+ DT.clear
22
+
23
+ queue_name = DT.queue
24
+ create_test_queue(queue_name, attributes: { 'VisibilityTimeout' => '2' })
25
+ Shoryuken.add_group('default', 1)
26
+ Shoryuken.add_queue(queue_name, 1, 'default')
27
+
28
+ # Exception classes for testing
29
+ PermanentValidationError = Class.new(StandardError)
30
+ TransientNetworkError = Class.new(StandardError)
31
+
32
+ # Worker with BOTH retry_intervals and non_retryable_exceptions configured
33
+ combined_worker = Class.new do
34
+ include Shoryuken::Worker
35
+
36
+ shoryuken_options auto_delete: true,
37
+ batch: false,
38
+ retry_intervals: [1, 1],
39
+ non_retryable_exceptions: [PermanentValidationError]
40
+
41
+ def perform(sqs_msg, body)
42
+ receive_count = sqs_msg.attributes['ApproximateReceiveCount'].to_i
43
+ DT[:attempts] << { receive_count: receive_count, body: body, time: Time.now }
44
+
45
+ case body
46
+ when 'non_retryable'
47
+ raise PermanentValidationError, 'Permanently invalid input'
48
+ when 'retryable'
49
+ # Fail on first 2 attempts (covered by retry_intervals), succeed on 3rd
50
+ raise TransientNetworkError, "Temporary failure on attempt #{receive_count}" if receive_count < 3
51
+
52
+ DT[:successful_processing] << { body: body, final_receive_count: receive_count }
53
+ end
54
+ end
55
+ end
56
+
57
+ combined_worker.get_shoryuken_options['queue'] = queue_name
58
+ Shoryuken.register_worker(queue_name, combined_worker)
59
+
60
+ queue_url = Shoryuken::Client.sqs.get_queue_url(queue_name: queue_name).queue_url
61
+
62
+ launcher = Shoryuken::Launcher.new
63
+ launcher.start
64
+
65
+ begin
66
+ # Test 1: a non-retryable exception must win over the backoff schedule -
67
+ # the message is attempted exactly once and deleted, never retried.
68
+ Shoryuken::Client.sqs.send_message(
69
+ queue_url: queue_url,
70
+ message_body: 'non_retryable'
71
+ )
72
+
73
+ Timeout.timeout(10) { sleep 0.5 until DT[:attempts].size >= 1 }
74
+
75
+ # Give a buggy retry every chance to happen: with retry_intervals [1, 1]
76
+ # a swallowed exception would bring the message back after ~1s.
77
+ sleep 5
78
+
79
+ non_retryable_attempts = DT[:attempts].select { |a| a[:body] == 'non_retryable' }
80
+ assert_equal(
81
+ 1,
82
+ non_retryable_attempts.size,
83
+ 'Non-retryable exception must not be retried even when retry_intervals is configured ' \
84
+ "(got #{non_retryable_attempts.size} attempts)"
85
+ )
86
+
87
+ attributes = Shoryuken::Client.sqs.get_queue_attributes(
88
+ queue_url: queue_url,
89
+ attribute_names: %w[ApproximateNumberOfMessages ApproximateNumberOfMessagesNotVisible]
90
+ ).attributes
91
+
92
+ total_messages = attributes['ApproximateNumberOfMessages'].to_i +
93
+ attributes['ApproximateNumberOfMessagesNotVisible'].to_i
94
+ assert_equal(
95
+ 0,
96
+ total_messages,
97
+ 'Message with non-retryable exception must be deleted immediately, not scheduled for retry'
98
+ )
99
+
100
+ # Test 2: exceptions NOT in the non-retryable list still follow the
101
+ # backoff schedule and eventually succeed.
102
+ Shoryuken::Client.sqs.send_message(
103
+ queue_url: queue_url,
104
+ message_body: 'retryable'
105
+ )
106
+
107
+ Timeout.timeout(20) { sleep 0.5 until DT[:successful_processing].size >= 1 }
108
+
109
+ retryable_attempts = DT[:attempts].select { |a| a[:body] == 'retryable' }
110
+ assert(retryable_attempts.size >= 3, 'Retryable exception should still go through the backoff schedule')
111
+ assert_equal('retryable', DT[:successful_processing].first[:body])
112
+ assert_equal(3, DT[:successful_processing].first[:final_receive_count])
113
+ ensure
114
+ Timeout.timeout(30) { launcher.stop }
115
+ end
@@ -3,7 +3,7 @@
3
3
  # This spec tests polling strategies including WeightedRoundRobin (default)
4
4
  # with multi-queue worker message distribution.
5
5
 
6
- setup_localstack
6
+ setup_sqs
7
7
 
8
8
  queue_high = DT.queues[0]
9
9
  queue_medium = DT.queues[1]
@@ -7,7 +7,7 @@
7
7
  # - Batch message sending
8
8
  # - Batch message deletion
9
9
 
10
- setup_localstack
10
+ setup_sqs
11
11
 
12
12
  # Test 1: Queue initialization by name
13
13
  queue_name = DT.uuid
@@ -5,7 +5,7 @@
5
5
 
6
6
  require 'concurrent'
7
7
 
8
- setup_localstack
8
+ setup_sqs
9
9
 
10
10
  queue_name = DT.queue
11
11
  create_test_queue(queue_name, attributes: { 'VisibilityTimeout' => '2' })