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,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A message without a CurrentAttributes key must not inherit a previous job's
4
+ # context. Every registered class is reset after each job - even keyless ones -
5
+ # so values set during one job don't leak into the next on a reused worker
6
+ # thread.
7
+
8
+ setup_sqs
9
+ setup_active_job
10
+
11
+ require 'active_support/current_attributes'
12
+ require 'shoryuken/active_job/current_attributes'
13
+
14
+ queue_name = DT.queue
15
+ create_test_queue(queue_name)
16
+
17
+ class LeakCurrent < ActiveSupport::CurrentAttributes
18
+ attribute :user_id
19
+ end
20
+
21
+ Shoryuken::ActiveJob::CurrentAttributes.persist(LeakCurrent)
22
+
23
+ class CrossJobResetTestJob < ActiveJob::Base
24
+ def perform
25
+ # Record what the worker thread sees at the start of the job, then dirty
26
+ # Current. Without a reset after a keyless job, the second execution on the
27
+ # same thread would observe 'leaked' instead of nil.
28
+ DT[:observed] << LeakCurrent.user_id
29
+ LeakCurrent.user_id = 'leaked'
30
+ end
31
+ end
32
+
33
+ CrossJobResetTestJob.queue_as(queue_name)
34
+
35
+ # Concurrency 1 so the two jobs run sequentially on the same worker thread.
36
+ Shoryuken.add_group('default', 1)
37
+ Shoryuken.add_queue(queue_name, 1, 'default')
38
+ Shoryuken.register_worker(queue_name, Shoryuken::ActiveJob::JobWrapper)
39
+
40
+ # Enqueued with an empty context, so both messages carry no cattr key.
41
+ CrossJobResetTestJob.perform_later
42
+ CrossJobResetTestJob.perform_later
43
+
44
+ poll_queues_until(timeout: 30) { DT[:observed].size >= 2 }
45
+
46
+ assert_equal([nil, nil], DT[:observed].first(2),
47
+ "CurrentAttributes leaked across jobs: #{DT[:observed].inspect}")
@@ -2,7 +2,7 @@
2
2
 
3
3
  # CurrentAttributes without any values set result in nil attributes during job execution
4
4
 
5
- setup_localstack
5
+ setup_sqs
6
6
  setup_active_job
7
7
 
8
8
  require 'active_support/current_attributes'
@@ -2,7 +2,7 @@
2
2
 
3
3
  # CurrentAttributes with full context are persisted and restored during job execution
4
4
 
5
- setup_localstack
5
+ setup_sqs
6
6
  setup_active_job
7
7
 
8
8
  require 'active_support/current_attributes'
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ # CurrentAttributes registered one class per persist call are all preserved.
4
+ #
5
+ # persist derived each storage key from the per-call index, so calling it once
6
+ # per class (as an app might across several initializers) made the third call
7
+ # reuse "cattr_0" and silently overwrite the second class - its attributes were
8
+ # then never serialized or restored.
9
+
10
+ setup_sqs
11
+ setup_active_job
12
+
13
+ require 'active_support/current_attributes'
14
+ require 'shoryuken/active_job/current_attributes'
15
+
16
+ queue_name = DT.queue
17
+ create_test_queue(queue_name)
18
+
19
+ class CurrentA < ActiveSupport::CurrentAttributes
20
+ attribute :a_val
21
+ end
22
+
23
+ class CurrentB < ActiveSupport::CurrentAttributes
24
+ attribute :b_val
25
+ end
26
+
27
+ class CurrentC < ActiveSupport::CurrentAttributes
28
+ attribute :c_val
29
+ end
30
+
31
+ # Registered incrementally, one class per call.
32
+ Shoryuken::ActiveJob::CurrentAttributes.persist(CurrentA)
33
+ Shoryuken::ActiveJob::CurrentAttributes.persist(CurrentB)
34
+ Shoryuken::ActiveJob::CurrentAttributes.persist(CurrentC)
35
+
36
+ # Every class must be registered, each under a distinct storage key.
37
+ registered = Shoryuken::ActiveJob::CurrentAttributes.cattrs
38
+ assert_equal(
39
+ %w[CurrentA CurrentB CurrentC],
40
+ registered.values.sort,
41
+ 'every persisted CurrentAttributes class must be registered (none silently dropped)'
42
+ )
43
+ assert_equal(3, registered.keys.uniq.size, 'each class must use a distinct storage key')
44
+
45
+ class IncrementalCurrentJob < ActiveJob::Base
46
+ def perform
47
+ DT[:executions] << {
48
+ a_val: CurrentA.a_val,
49
+ b_val: CurrentB.b_val,
50
+ c_val: CurrentC.c_val
51
+ }
52
+ end
53
+ end
54
+
55
+ IncrementalCurrentJob.queue_as(queue_name)
56
+
57
+ Shoryuken.add_group('default', 1)
58
+ Shoryuken.add_queue(queue_name, 1, 'default')
59
+ Shoryuken.register_worker(queue_name, Shoryuken::ActiveJob::JobWrapper)
60
+
61
+ CurrentA.a_val = 'a-value'
62
+ CurrentB.b_val = 'b-value'
63
+ CurrentC.c_val = 'c-value'
64
+
65
+ IncrementalCurrentJob.perform_later
66
+
67
+ CurrentA.reset
68
+ CurrentB.reset
69
+ CurrentC.reset
70
+
71
+ poll_queues_until(timeout: 30) { DT[:executions].size >= 1 }
72
+
73
+ result = DT[:executions].first
74
+ assert_equal('a-value', result[:a_val])
75
+ assert_equal('b-value', result[:b_val], 'the second incrementally-persisted class must not be dropped')
76
+ assert_equal('c-value', result[:c_val])
@@ -2,7 +2,7 @@
2
2
 
3
3
  # CurrentAttributes with partial values set preserve only set attributes during job execution
4
4
 
5
- setup_localstack
5
+ setup_sqs
6
6
  setup_active_job
7
7
 
8
8
  require 'active_support/current_attributes'
@@ -2,7 +2,7 @@
2
2
 
3
3
  # ActiveJob custom numeric message attributes are sent to SQS with correct data type
4
4
 
5
- setup_localstack
5
+ setup_sqs
6
6
  setup_active_job
7
7
 
8
8
  queue_name = DT.queue
@@ -2,7 +2,7 @@
2
2
 
3
3
  # ActiveJob custom string message attributes are sent to SQS and preserved
4
4
 
5
- setup_localstack
5
+ setup_sqs
6
6
  setup_active_job
7
7
 
8
8
  queue_name = DT.queue
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ # This spec tests opting out of the automatic FIFO message_deduplication_id that
6
+ # the ActiveJob adapter generates.
7
+ #
8
+ # For FIFO queues the adapter derives a content-based message_deduplication_id
9
+ # from the serialized job minus job_id/enqueued_at (see issues #457 / #750). That
10
+ # means two *distinct* enqueues of the same job class + args within SQS's 5-minute
11
+ # window collapse into one - the second is silently dropped. That is intentional,
12
+ # but it is a "skipped message" trap for users who legitimately enqueue the same
13
+ # work twice.
14
+ #
15
+ # Expected behavior: setting Shoryuken.active_job_fifo_message_deduplication = false
16
+ # stops the adapter from generating the id, so those enqueues are no longer
17
+ # silently deduplicated. The default (true) preserves the existing behavior.
18
+ #
19
+ # Regression: there was no way to disable the auto-generated deduplication id.
20
+
21
+ setup_active_job
22
+
23
+ class FifoOptOutJob < ActiveJob::Base
24
+ queue_as :fifo_opt_out
25
+
26
+ def perform(_arg); end
27
+ end
28
+
29
+ fifo_queue_mock = Object.new
30
+ fifo_queue_mock.define_singleton_method(:fifo?) { true }
31
+ fifo_queue_mock.define_singleton_method(:name) { 'fifo_opt_out.fifo' }
32
+
33
+ captured = nil
34
+ fifo_queue_mock.define_singleton_method(:send_message) { |params| captured = params }
35
+
36
+ Shoryuken::Client.define_singleton_method(:queues) { |_queue_name = nil| fifo_queue_mock }
37
+ Shoryuken.define_singleton_method(:register_worker) { |*| nil }
38
+
39
+ # Default behavior: the adapter sets a content-based dedup id (excluding the
40
+ # per-enqueue job_id/enqueued_at), so two identical jobs would collapse to one.
41
+ FifoOptOutJob.perform_later('same-args')
42
+
43
+ assert(
44
+ captured.key?(:message_deduplication_id),
45
+ 'by default a FIFO ActiveJob send gets an auto-generated message_deduplication_id'
46
+ )
47
+
48
+ body = captured[:message_body]
49
+ expected = Digest::SHA256.hexdigest(JSON.dump(body.except('job_id', 'enqueued_at')))
50
+ assert_equal(expected, captured[:message_deduplication_id])
51
+
52
+ # Opt-out: with deduplication disabled the adapter no longer sets a dedup id, so
53
+ # distinct enqueues of identical jobs are not silently dropped.
54
+ Shoryuken.active_job_fifo_message_deduplication = false
55
+ captured = nil
56
+ FifoOptOutJob.perform_later('same-args')
57
+
58
+ refute(
59
+ captured.key?(:message_deduplication_id),
60
+ 'with deduplication disabled the adapter must not set message_deduplication_id'
61
+ )
62
+
63
+ # An explicit deduplication id must still be honored even when auto-generation is off.
64
+ captured = nil
65
+ FifoOptOutJob.set(message_deduplication_id: 'explicit-id').perform_later('same-args')
66
+
67
+ assert_equal('explicit-id', captured[:message_deduplication_id])
@@ -6,7 +6,7 @@
6
6
  # In Shoryuken 7.0, the SQSSendMessageParametersSupport module's initialize method
7
7
  # breaks keyword argument passing to ActiveJob jobs because it lacks ruby2_keywords.
8
8
 
9
- setup_localstack
9
+ setup_sqs
10
10
  setup_active_job
11
11
 
12
12
  queue_name = DT.queue
@@ -2,7 +2,7 @@
2
2
 
3
3
  # ActiveJob discard_on discards jobs that raise specific errors without retry
4
4
 
5
- setup_localstack
5
+ setup_sqs
6
6
  setup_active_job
7
7
 
8
8
  queue_name = DT.queue
@@ -2,7 +2,7 @@
2
2
 
3
3
  # ActiveJob retry_on re-enqueues failed jobs until they succeed or exhaust attempts
4
4
 
5
- setup_localstack
5
+ setup_sqs
6
6
  setup_active_job
7
7
 
8
8
  queue_name = DT.queue
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Full round-trip ActiveJob integration test
4
- # Enqueues a job via ActiveJob → sends to LocalStack SQS → processes via Shoryuken → verifies execution
4
+ # Enqueues a job via ActiveJob → sends to ElasticMQ SQS → processes via Shoryuken → verifies execution
5
5
 
6
- setup_localstack
6
+ setup_sqs
7
7
  setup_active_job
8
8
 
9
9
  queue_name = DT.queue
@@ -3,7 +3,7 @@
3
3
  # Scheduled ActiveJob integration test
4
4
  # Tests jobs scheduled with set(wait:) are delivered after the delay
5
5
 
6
- setup_localstack
6
+ setup_sqs
7
7
  setup_active_job
8
8
 
9
9
  queue_name = DT.queue
@@ -3,7 +3,7 @@
3
3
  # This spec tests the ActiveRecord middleware functionality.
4
4
  # The middleware clears database connections after each message is processed.
5
5
 
6
- setup_localstack
6
+ setup_sqs
7
7
 
8
8
  queue_name = DT.queue
9
9
  create_test_queue(queue_name)
@@ -3,7 +3,7 @@
3
3
  # This spec tests the auto_delete middleware functionality.
4
4
  # When auto_delete: true, messages should be automatically deleted after successful processing.
5
5
 
6
- setup_localstack
6
+ setup_sqs
7
7
 
8
8
  queue_name = DT.queue
9
9
  create_test_queue(queue_name)
@@ -4,7 +4,7 @@
4
4
  # When auto_visibility_timeout: true, the message visibility timeout should be
5
5
  # automatically extended during long-running job processing to prevent re-delivery.
6
6
 
7
- setup_localstack
7
+ setup_sqs
8
8
 
9
9
  queue_name = DT.uuid
10
10
 
@@ -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
+ )
@@ -10,7 +10,7 @@ Shoryuken.sqs_client = nil
10
10
  # Configure AWS options programmatically (simulating PORO setup with config file)
11
11
  Shoryuken.options[:aws] = {
12
12
  region: 'us-east-1',
13
- endpoint: 'http://localhost:4566',
13
+ endpoint: 'http://localhost:9324',
14
14
  access_key_id: 'test-key-from-config',
15
15
  secret_access_key: 'test-secret-from-config'
16
16
  }
@@ -22,7 +22,7 @@ client = Shoryuken.sqs_client
22
22
  config = client.config
23
23
 
24
24
  assert_equal('us-east-1', config.region, "Region should be set from options[:aws]")
25
- assert_equal('http://localhost:4566', config.endpoint.to_s, "Endpoint should be set from options[:aws]")
25
+ assert_equal('http://localhost:9324', config.endpoint.to_s, "Endpoint should be set from options[:aws]")
26
26
 
27
27
  # Verify the client actually works by creating a queue
28
28
  queue_name = "aws-config-test-#{SecureRandom.hex(6)}"
@@ -49,7 +49,7 @@ assert_equal('us-west-2', client2.config.region, "New client should use updated
49
49
  Shoryuken.sqs_client = nil
50
50
  Shoryuken.options[:aws] = {
51
51
  region: 'us-east-1',
52
- endpoint: 'http://localhost:4566',
52
+ endpoint: 'http://localhost:9324',
53
53
  access_key_id: 'another-key',
54
54
  secret_access_key: 'another-secret'
55
55
  }
@@ -4,7 +4,7 @@
4
4
  # messages), batch vs single worker behavior differences, JSON body parsing in
5
5
  # batch mode, and maximum batch size handling.
6
6
 
7
- setup_localstack
7
+ setup_sqs
8
8
 
9
9
  queue_name = DT.queue
10
10
  create_test_queue(queue_name)
@@ -3,7 +3,7 @@
3
3
  # This spec tests the body_parser option with :json setting
4
4
  # Verifies that JSON messages are automatically parsed into Ruby hashes
5
5
 
6
- setup_localstack
6
+ setup_sqs
7
7
 
8
8
  queue_name = DT.uuid
9
9
  create_test_queue(queue_name)
@@ -3,7 +3,7 @@
3
3
  # This spec tests the body_parser option with a custom Proc
4
4
  # Verifies that custom parsing logic can be applied to messages
5
5
 
6
- setup_localstack
6
+ setup_sqs
7
7
 
8
8
  queue_name = DT.uuid
9
9
  create_test_queue(queue_name)
@@ -3,7 +3,7 @@
3
3
  # This spec tests the body_parser option with :text setting (default)
4
4
  # Verifies that messages are returned as plain strings
5
5
 
6
- setup_localstack
6
+ setup_sqs
7
7
 
8
8
  queue_name = DT.uuid
9
9
  create_test_queue(queue_name)
@@ -4,7 +4,7 @@
4
4
 
5
5
  require 'concurrent'
6
6
 
7
- setup_localstack
7
+ setup_sqs
8
8
 
9
9
  queue_name = DT.queue
10
10
  create_test_queue(queue_name)
@@ -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,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This spec tests that a custom polling strategy can be configured per-group
4
+ # via the add_group API.
5
+ #
6
+ # Bug #925: Cannot configure custom polling_strategy from YAML config
7
+ # The add_group method does not accept a polling_strategy parameter,
8
+ # making it impossible to set a per-group polling strategy programmatically.
9
+ # Additionally, polling_strategy() reads from options[:groups] (raw config)
10
+ # rather than from groups (the processed hash populated by add_group).
11
+
12
+ # Define a custom polling strategy
13
+ class CustomRoundRobin < Shoryuken::Polling::BaseStrategy
14
+ def initialize(queues, delay = nil)
15
+ @queues = queues.dup.uniq
16
+ @delay = delay
17
+ @index = 0
18
+ end
19
+
20
+ def next_queue
21
+ return nil if @queues.empty?
22
+
23
+ queue = @queues[@index % @queues.length]
24
+ @index += 1
25
+ Shoryuken::Polling::QueueConfiguration.new(queue, {})
26
+ end
27
+
28
+ def messages_found(_queue, _count)
29
+ # noop
30
+ end
31
+
32
+ def active_queues
33
+ @queues.map { |q| [q, 1] }
34
+ end
35
+ end
36
+
37
+ # ---- Part 1: API assertions (no SQS needed) ----
38
+ # add_group accepts polling_strategy: keyword argument
39
+ Shoryuken.add_group('custom_group', 1, polling_strategy: CustomRoundRobin)
40
+
41
+ # polling_strategy() returns our custom strategy for the group
42
+ strategy = Shoryuken.polling_strategy('custom_group')
43
+ assert_equal(
44
+ CustomRoundRobin,
45
+ strategy,
46
+ "Expected polling_strategy('custom_group') to return CustomRoundRobin, got #{strategy.inspect}"
47
+ )
48
+
49
+ # add_group raises InvalidPollingStrategyError for invalid types
50
+ begin
51
+ Shoryuken.add_group('bad_group', 1, polling_strategy: 123)
52
+ raise 'Expected InvalidPollingStrategyError to be raised'
53
+ rescue Shoryuken::Errors::InvalidPollingStrategyError
54
+ # expected
55
+ end
56
+
57
+ # ---- Part 2: End-to-end with SQS ----
58
+ setup_sqs
59
+
60
+ queue_name = DT.queues[0]
61
+ create_test_queue(queue_name)
62
+
63
+ Shoryuken.groups.clear
64
+ Shoryuken.add_group('custom_group', 1, polling_strategy: CustomRoundRobin)
65
+ Shoryuken.add_queue(queue_name, 1, 'custom_group')
66
+
67
+ worker_class = Class.new do
68
+ include Shoryuken::Worker
69
+
70
+ shoryuken_options auto_delete: true, batch: false
71
+
72
+ def perform(_sqs_msg, body)
73
+ DT[:processed] << body
74
+ end
75
+ end
76
+
77
+ worker_class.get_shoryuken_options['queue'] = queue_name
78
+ Shoryuken.register_worker(queue_name, worker_class)
79
+
80
+ Shoryuken::Client.queues(queue_name).send_message(message_body: 'custom-strategy-msg')
81
+
82
+ sleep 1
83
+
84
+ poll_queues_until { DT[:processed].size >= 1 }
85
+
86
+ assert_equal(1, DT[:processed].size)
87
+ assert_equal('custom-strategy-msg', DT[:processed].first)
@@ -5,7 +5,7 @@
5
5
  # Note: This test doesn't use poll_queues_until because messages should fail
6
6
  # and be moved to DLQ rather than being successfully processed.
7
7
 
8
- setup_localstack
8
+ setup_sqs
9
9
 
10
10
  main_queue_name = DT.queues[0]
11
11
  dlq_name = DT.queues[1]
@@ -3,7 +3,7 @@
3
3
  # This spec tests custom exception handlers.
4
4
  # Exception handlers are called when a worker raises an error.
5
5
 
6
- setup_localstack
6
+ setup_sqs
7
7
 
8
8
  queue_name = DT.queue
9
9
  create_test_queue(queue_name, attributes: { 'VisibilityTimeout' => '2' })
@@ -4,7 +4,7 @@
4
4
  # When retry_intervals is configured, failed jobs should have their visibility
5
5
  # timeout adjusted based on the retry attempt number.
6
6
 
7
- setup_localstack
7
+ setup_sqs
8
8
 
9
9
  queue_name = DT.queue
10
10