shoryuken 7.0.2 → 7.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) 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/.yard-lint.yml +29 -1
  7. data/CHANGELOG.md +171 -0
  8. data/Gemfile.lint.lock +8 -7
  9. data/bin/cli/sqs.rb +67 -3
  10. data/bin/integrations +52 -34
  11. data/lib/active_job/extensions.rb +5 -8
  12. data/lib/active_job/queue_adapters/shoryuken_adapter.rb +6 -5
  13. data/lib/shoryuken/active_job/current_attributes.rb +33 -7
  14. data/lib/shoryuken/body_parser.rb +1 -2
  15. data/lib/shoryuken/client.rb +15 -6
  16. data/lib/shoryuken/default_exception_handler.rb +2 -3
  17. data/lib/shoryuken/errors.rb +2 -4
  18. data/lib/shoryuken/fetcher.rb +7 -1
  19. data/lib/shoryuken/helpers/atomic_counter.rb +6 -9
  20. data/lib/shoryuken/helpers/atomic_hash.rb +9 -15
  21. data/lib/shoryuken/helpers/hash_utils.rb +5 -7
  22. data/lib/shoryuken/helpers/string_utils.rb +6 -8
  23. data/lib/shoryuken/helpers/timer_task.rb +19 -2
  24. data/lib/shoryuken/inline_message.rb +3 -4
  25. data/lib/shoryuken/launcher.rb +19 -8
  26. data/lib/shoryuken/manager.rb +65 -6
  27. data/lib/shoryuken/message.rb +2 -3
  28. data/lib/shoryuken/middleware/chain.rb +7 -13
  29. data/lib/shoryuken/middleware/entry.rb +1 -2
  30. data/lib/shoryuken/middleware/server/auto_extend_visibility.rb +30 -2
  31. data/lib/shoryuken/middleware/server/exponential_backoff_retry.rb +40 -18
  32. data/lib/shoryuken/middleware/server/non_retryable_exception.rb +17 -8
  33. data/lib/shoryuken/middleware/server/timing.rb +2 -3
  34. data/lib/shoryuken/options.rb +37 -4
  35. data/lib/shoryuken/polling/base_strategy.rb +16 -23
  36. data/lib/shoryuken/polling/queue_configuration.rb +8 -12
  37. data/lib/shoryuken/polling/strict_priority.rb +26 -14
  38. data/lib/shoryuken/polling/weighted_round_robin.rb +40 -27
  39. data/lib/shoryuken/queue.rb +41 -4
  40. data/lib/shoryuken/util.rb +4 -1
  41. data/lib/shoryuken/version.rb +1 -1
  42. data/lib/shoryuken/worker/default_executor.rb +11 -5
  43. data/lib/shoryuken/worker/inline_executor.rb +10 -5
  44. data/lib/shoryuken/worker.rb +15 -13
  45. data/lib/shoryuken.rb +6 -3
  46. data/renovate.json +16 -2
  47. data/shoryuken.gemspec +9 -0
  48. data/spec/integration/active_job/current_attributes/cross_job_reset_spec.rb +47 -0
  49. data/spec/integration/active_job/current_attributes/incremental_persist_spec.rb +76 -0
  50. data/spec/integration/active_job/fifo_dedup_opt_out/fifo_dedup_opt_out_spec.rb +67 -0
  51. data/spec/integration/auto_extend_visibility/short_visibility_timeout_spec.rb +52 -0
  52. data/spec/integration/cli/find_all_spec.rb +120 -0
  53. data/spec/integration/concurrent_processing/processor_accounting_spec.rb +94 -0
  54. data/spec/integration/fifo_ordering/fifo_max_messages_cap_spec.rb +96 -0
  55. data/spec/integration/launcher/double_graceful_stop_spec.rb +71 -0
  56. data/spec/integration/launcher/embedded_dispatch_error_spec.rb +85 -0
  57. data/spec/integration/launcher/global_executor_preserved_spec.rb +76 -0
  58. data/spec/integration/launcher/graceful_stop_timeout_spec.rb +74 -0
  59. data/spec/integration/message_operations/partial_batch_delete_spec.rb +67 -0
  60. data/spec/integration/non_retryable_exception/non_retryable_exception_spec.rb +1 -1
  61. data/spec/integration/non_retryable_exception/with_retry_intervals_spec.rb +115 -0
  62. data/spec/integrations_helper.rb +10 -9
  63. data/spec/lib/shoryuken/client_spec.rb +62 -0
  64. data/spec/lib/shoryuken/fetcher_spec.rb +13 -0
  65. data/spec/lib/shoryuken/helpers/timer_task_spec.rb +24 -0
  66. data/spec/lib/shoryuken/launcher_spec.rb +38 -0
  67. data/spec/lib/shoryuken/manager_spec.rb +147 -0
  68. data/spec/lib/shoryuken/middleware/server/auto_extend_visibility_spec.rb +35 -0
  69. data/spec/lib/shoryuken/middleware/server/exponential_backoff_retry_spec.rb +88 -0
  70. data/spec/lib/shoryuken/polling/strict_priority_spec.rb +25 -0
  71. data/spec/lib/shoryuken/polling/weighted_round_robin_spec.rb +50 -0
  72. data/spec/lib/shoryuken/queue_spec.rb +123 -0
  73. data/spec/lib/shoryuken/util_spec.rb +26 -0
  74. data/spec/lib/shoryuken/worker/default_executor_spec.rb +13 -0
  75. data/spec/lib/shoryuken/worker/inline_executor_spec.rb +12 -0
  76. data/spec/shared_examples_for_active_job.rb +18 -0
  77. data/spec/spec_helper.rb +40 -20
  78. metadata +35 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 63763ea11f3377475150b2270c7162fa5cb9a35e39cdd2a534e353837dc3e6ff
4
- data.tar.gz: f265d74ebdb8a087cb658a8f442489523305e7cd551e5fae4ae16f9709f238ae
3
+ metadata.gz: b332f880ab52b8380e13758f98bbca5d35a5a21f693753d536564c2dc760eadb
4
+ data.tar.gz: 9660f222a93c1fc517cc7f65bf029ee6c31e9a6b5526d67af917a91e64768ca4
5
5
  SHA512:
6
- metadata.gz: 012a20c2c6cf74c8af2be01f046e137f74df8f6ca0746bc3c05f3dfc6eb45d0bf320a4a6493917c8c12371f94dafb0777873eb71d4298f097ecdb1bf9f9b2fd5
7
- data.tar.gz: e257ec56be80a862449937cf882fd2dfb42037c4b9e1bbfe2987463a45e7bd0857a18a94bafd225ecb3e170180a62d59b4e3cd926d863246621d6c5683bce9a1
6
+ metadata.gz: ce8200034dfe12e010bbb928f73d7ae35f2abfda4d85235cc7af189d26306d254bfd84e6956dace838ba0a222768628cf2c4785f657f8c23cd07ddc47219064a
7
+ data.tar.gz: c7a947da8aa8dadd9f79bedd89d02a67cfd715e715294d5e38a396cbb7070e5a06b4aa4055e347493eaa9373242593a5eacaed27ebb7528f16b21d8007a44825
@@ -19,12 +19,12 @@ jobs:
19
19
  id-token: write
20
20
 
21
21
  steps:
22
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
22
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
23
23
  with:
24
24
  fetch-depth: 0
25
25
 
26
26
  - name: Set up Ruby
27
- uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0
27
+ uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
28
28
  with:
29
29
  bundler-cache: false
30
30
 
@@ -33,4 +33,4 @@ jobs:
33
33
  bundle install --jobs 4 --retry 3
34
34
 
35
35
  # Release
36
- - uses: rubygems/release-gem@6317d8d1f7e28c24d28f6eff169ea854948bd9f7 # v1.2.0
36
+ - uses: rubygems/release-gem@7f9650160c1a4e7989fdc9855807bdbd421d8b6b # v1.4.1
@@ -11,17 +11,41 @@ jobs:
11
11
  matrix:
12
12
  ruby: ['3.2', '3.3', '3.4', '4.0']
13
13
  runs-on: ubuntu-latest
14
+ # Unit specs finish in under a minute; fail fast instead of letting a
15
+ # wedged runner occupy the job slot for the 6-hour default
16
+ timeout-minutes: 10
14
17
  steps:
15
18
  - name: Checkout code
16
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
19
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
17
20
 
18
- - uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0
21
+ - uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
19
22
  with:
20
23
  ruby-version: ${{ matrix.ruby }}
21
24
  bundler-cache: true
22
25
 
23
26
  - name: Run specs
24
- run: bundle exec rake spec
27
+ run: |
28
+ # Run rspec directly (not via rake) so $SPEC_PID is the ruby process
29
+ # itself — the USR1 watchdog below sends the signal to that pid, and
30
+ # spec_helper registers a USR1 handler that prints a thread dump.
31
+ # Running via 'bundle exec rake spec' would send the signal to rake,
32
+ # which has no USR1 handler and would crash before the dump appears.
33
+ bundle exec rspec --format documentation &
34
+ SPEC_PID=$!
35
+ # Send USR1 after 8 minutes so the thread-dump handler in spec_helper
36
+ # prints a backtrace before the 10-minute job timeout kills everything.
37
+ ( sleep 480
38
+ if kill -0 $SPEC_PID 2>/dev/null; then
39
+ echo "=== specs still running after 8 minutes — requesting thread dump ==="
40
+ kill -USR1 $SPEC_PID 2>/dev/null || true
41
+ sleep 5
42
+ fi
43
+ ) &
44
+ WATCHDOG_PID=$!
45
+ wait $SPEC_PID
46
+ EXIT_CODE=$?
47
+ kill $WATCHDOG_PID 2>/dev/null || true
48
+ exit $EXIT_CODE
25
49
 
26
50
  integrations:
27
51
  name: Integrations
@@ -29,9 +53,11 @@ jobs:
29
53
  matrix:
30
54
  ruby: ['3.2', '3.3', '3.4', '4.0']
31
55
  runs-on: ubuntu-latest
56
+ # Integration specs normally take ~4 minutes
57
+ timeout-minutes: 15
32
58
  steps:
33
59
  - name: Checkout code
34
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
60
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
35
61
 
36
62
  - name: Start ElasticMQ
37
63
  run: docker compose up -d
@@ -45,7 +71,7 @@ jobs:
45
71
  done
46
72
  '
47
73
 
48
- - uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0
74
+ - uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
49
75
  with:
50
76
  ruby-version: ${{ matrix.ruby }}
51
77
  bundler-cache: true
@@ -56,6 +82,7 @@ jobs:
56
82
  ci-success:
57
83
  name: CI Success
58
84
  runs-on: ubuntu-latest
85
+ timeout-minutes: 5
59
86
  if: always()
60
87
  needs:
61
88
  - specs
@@ -7,7 +7,7 @@ jobs:
7
7
  verify:
8
8
  runs-on: ubuntu-latest
9
9
  steps:
10
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
10
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
11
11
  - name: Check SHA pins
12
12
  run: |
13
13
  if grep -E -r "uses: .*/.*@(v[0-9]+|main|master)($|[[:space:]]|$)" --include="*.yml" --include="*.yaml" .github/workflows/ | grep -v "#"; then
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 4.0.2
1
+ 4.0.7
data/.yard-lint.yml CHANGED
@@ -66,6 +66,34 @@ Documentation/BlankLineBeforeDefinition:
66
66
  SingleBlankLine: true
67
67
  OrphanedDocs: true
68
68
 
69
+ Documentation/DuplicateNamespaceComment:
70
+ Description: Detects namespaces documented with a YARD comment in more than one file.
71
+ Enabled: true
72
+ Severity: error
73
+
74
+ Documentation/UnderfilledLines:
75
+ Description: Detects documentation prose that wraps too early and wastes horizontal space.
76
+ Enabled: true
77
+ Severity: error
78
+ # Aligned with RuboCop's Layout/LineLength so documentation prose wraps to the same
79
+ # width as code.
80
+ MaxLength: 125
81
+
82
+ Documentation/LineLength:
83
+ Description: Detects documentation lines that exceed the maximum length.
84
+ Enabled: true
85
+ Severity: error
86
+ # Aligned with RuboCop's Layout/LineLength.
87
+ MaxLength: 125
88
+
89
+ Documentation/TextSubstitution:
90
+ Description: Detects em/en-dashes in documentation and replaces them with hyphens.
91
+ Enabled: true
92
+ Severity: error
93
+ Substitutions:
94
+ "—": "-" # em-dash (U+2014)
95
+ "–": "-" # en-dash (U+2013)
96
+
69
97
  # Tags validators
70
98
  Tags/Order:
71
99
  Description: Enforces consistent ordering of YARD tags.
@@ -146,7 +174,7 @@ Tags/OptionTags:
146
174
  Tags/ExampleSyntax:
147
175
  Description: Validates Ruby syntax in @example tags.
148
176
  Enabled: true
149
- Severity: warning
177
+ Severity: error
150
178
 
151
179
  Tags/RedundantParamDescription:
152
180
  Description: Detects meaningless parameter descriptions that add no value.
data/CHANGELOG.md CHANGED
@@ -1,3 +1,174 @@
1
+ ## [7.0.4] - 2026-09-23
2
+
3
+ - Fix: `shoryuken sqs dump`/`mv` no longer stop early on real SQS (mensfeld)
4
+ - `find_all` used short polling and broke on the first empty `receive_message` response. Real
5
+ (distributed) SQS routinely returns an empty batch while the queue still has messages, so dump/mv
6
+ quietly processed only a fraction of the queue (invisible on single-node ElasticMQ/LocalStack)
7
+ - It now long-polls and only stops after several consecutive empty batches, so the queue is actually drained
8
+ - Because a longer drain can outlast the queue's visibility timeout (dump/mv delete only after `find_all`
9
+ returns), re-read messages are now deduplicated by message id, so they are not dumped/moved or counted
10
+ twice, and the newest receipt handle is kept so the deferred delete still succeeds
11
+ - Deletion stays deferred (a failed dump/mv deletes nothing), so a queue larger than SQS's in-flight limit
12
+ (~120k standard / ~20k FIFO) drains over multiple runs rather than one - no message is lost
13
+
14
+ - Fix: Graceful stop no longer deadlocks when a manager's dispatch loop never started (mensfeld)
15
+ - `await_dispatching_in_progress` blocks on a `Queue` that is only closed from inside `dispatch_loop` when it
16
+ observes the stop flag. If the loop never runs - a graceful stop arriving before the start `Future` is
17
+ scheduled, or an embedded host whose executor pool is saturated so `manager.start` never runs - the signal
18
+ was never closed and `Launcher#stop` deadlocked
19
+ - `stop_new_dispatching` now closes the release signal itself when the loop hasn't started (tracked via a new
20
+ flag); `Queue#close` is idempotent, so a loop that does start later and closes it again is harmless
21
+
22
+ - Feature: `Shoryuken.fifo_message_deduplication` to opt out of content-based dedup id generation for raw sends (mensfeld)
23
+ - `Queue#add_fifo_attributes!` always set `message_deduplication_id` to a SHA256 of the body when none was
24
+ given, so two raw sends of an identical body within SQS's 5-minute window (`Worker.perform_async`,
25
+ `Queue#send_message`/`#send_messages`) were silently deduplicated - the second dropped. ActiveJob got an
26
+ opt-out in #1017 but the raw path had none
27
+ - Set `Shoryuken.fifo_message_deduplication = false` to stop auto-generating that id, so identical bodies are
28
+ no longer silently dropped (provide a `message_deduplication_id` yourself or enable
29
+ `ContentBasedDeduplication` on the queue)
30
+ - Defaults to `true`, preserving the existing behavior; an explicit `message_deduplication_id` is still honored
31
+
32
+ - Fix: Harden `ExponentialBackoffRetry` visibility handling (mensfeld)
33
+ - `next_visibility_timeout` could return a negative value when a job ran past the 12h SQS ceiling (its
34
+ `max_timeout` goes below zero), and `change_message_visibility` rejects a negative timeout; it now clamps
35
+ to 0 so the message is retried as soon as possible
36
+ - `handle_failure` called `change_visibility` unguarded, so a failure there (e.g. an expired receipt handle)
37
+ escaped the `rescue` in `#call` and masked the original worker error from exception handlers/notifiers; it
38
+ now rescues, logs, and reports "not retried" so the original error is re-raised and the message falls back
39
+ to the queue's default visibility timeout
40
+ - The failure `rescue` in `#call` is now scoped to the single-message `yield` only, so a batch worker's error
41
+ no longer routes the message Array into `handle_failure` (raising a `NoMethodError` that masked the original)
42
+
43
+ - Fix: `perform_async` no longer mutates the caller-supplied options hash (mensfeld)
44
+ - `DefaultExecutor#perform_async` and `InlineExecutor#perform_async` deleted `:queue`, injected
45
+ `:message_body`, and wrote `shoryuken_class` into the nested `:message_attributes` in place, so a caller
46
+ reusing one options hash across enqueues had `:queue` stripped after the first call - silently routing later
47
+ jobs to the worker's default queue
48
+ - Both now operate on a `dup` and rebuild `:message_attributes` with `merge`, leaving the caller's hash untouched
49
+
50
+ - Fix: `Shoryuken::Client.queues` no longer builds the same queue more than once under concurrency (mensfeld)
51
+ - The cache used an unsynchronized `@@queues[name] ||= Shoryuken::Queue.new(...)`. Building a queue makes
52
+ SQS API calls, and that I/O releases the GVL, so concurrent first-access (dispatch, processor-completion
53
+ and worker threads all call it) built the queue multiple times - redundant API calls, and a corrupt cache
54
+ on JRuby/TruffleRuby
55
+ - Access to the cache is now guarded by a mutex
56
+
57
+ - Fix: Busy-processor counter no longer leaks when the executor rejects a worker post (mensfeld)
58
+ - `Manager#assign` increments `@busy_processors` before posting the worker `Concurrent::Promise`, but the
59
+ matching decrement (`processor_done`) runs inside the promise body. When the post is rejected with
60
+ `Concurrent::RejectedExecutionError` - a hard stop racing the `running?` check, or a saturated bounded custom
61
+ `launcher_executor` - the body never runs and the counter leaks
62
+ - With a bounded executor the leak is permanent: `ready` (`@max_processors - busy`) keeps shrinking until
63
+ dispatch stalls and the group silently stops processing
64
+ - The increment is now rolled back on `RejectedExecutionError` by decrementing directly (the message was never
65
+ processed, so the FIFO `message_processed` callback must not run) (#1029)
66
+
67
+ ## [7.0.3] - 2026-07-10
68
+
69
+ - Feature: `Shoryuken.active_job_fifo_message_deduplication` to opt out of FIFO dedup id generation (mensfeld)
70
+ - For FIFO queues the ActiveJob adapter derives a content-based `message_deduplication_id` from the
71
+ serialized job minus `job_id`/`enqueued_at` (#457 / #750), so two distinct enqueues of the same job
72
+ class and arguments within SQS's 5-minute window silently collapse into one - a "skipped message" trap
73
+ - Set `Shoryuken.active_job_fifo_message_deduplication = false` to stop generating that id, so identical
74
+ jobs are no longer silently dropped (rely on the queue's content-based deduplication or explicit ids)
75
+ - Defaults to `true`, preserving the existing behavior; an explicit `message_deduplication_id` is still honored
76
+
77
+ - Fix: Polling strategies are now thread-safe, and WeightedRoundRobin unpauses processed queues reliably (mensfeld)
78
+ - `message_processed` runs on processor-completion threads (for FIFO queues) while `next_queue`/`messages_found`
79
+ run on the dispatch thread; they mutate the same state with no synchronization, which is benign on MRI (GVL)
80
+ but corrupts state on JRuby/TruffleRuby. Both `WeightedRoundRobin` and `StrictPriority` now serialize access
81
+ with a mutex
82
+ - `WeightedRoundRobin#unpause_queues` only checked the head of the paused list, so a queue marked ready by
83
+ `message_processed` could stay stuck behind an earlier-paused queue; it now unpauses the first expired entry
84
+ anywhere in the list
85
+
86
+ - Fix: `TimerTask#kill` no longer deadlocks on Ruby 3.2 under concurrent callers (mensfeld)
87
+ - `kill` called `@thread.kill` while holding `@mutex`; the timer loop's `ensure` block calls
88
+ `@mutex.synchronize` to clear `@running`, so on Ruby 3.2 (where `Thread#kill` yields the GVL
89
+ to the killed thread for cleanup before returning) both threads waited on each other forever
90
+ - The thread is now killed after the mutex is released, so the ensure block can always acquire it
91
+
92
+ - Fix: Stopping the launcher no longer destroys the process-global IO executor (mensfeld)
93
+ - With no `launcher_executor` configured, `Launcher#executor` fell back to `Concurrent.global_io_executor`,
94
+ and `Launcher#stop`/`#stop!` call `shutdown` (and `kill`) on it
95
+ - Shutting down that process-wide pool broke anything else relying on concurrent-ruby's `:io` pool
96
+ (including Shoryuken's own `ShoryukenConcurrentSendAdapter`) and prevented starting a fresh launcher
97
+ in the same process
98
+ - The launcher now owns a dedicated `Concurrent::CachedThreadPool`, so stopping it leaves the global
99
+ IO executor untouched
100
+
101
+ - Fix: `Queue#delete_messages` now logs every batch-delete failure and returns a robust boolean (mensfeld)
102
+ - It used `failed.any? { |f| logger.error ... }`, which short-circuited after the first failure - so when
103
+ a batch had multiple failures only the first was logged - and only returned truthy because `Logger#error`
104
+ happens to return true (a custom logger returning falsey would have hidden the failure from callers)
105
+ - It now logs each failure and returns `failed.any?`, so `NonRetryableException`/`AutoDelete` reliably see
106
+ that some messages may remain and need reprocessing
107
+
108
+ - Fix: Lifecycle events fired in reverse no longer alternate handler order (mensfeld)
109
+ - `Util#fire_event` reversed the stored handler array in place with `reverse!`, so an event fired more
110
+ than once with `reverse: true` (e.g. `:shutdown` via `stop` then `stop!`) flipped order each time
111
+ - It now reverses a copy, leaving the stored handler order untouched
112
+
113
+ - Fix: A fatal dispatch error no longer hard-kills an embedded host process (mensfeld)
114
+ - `Manager#handle_dispatch_error` sent `Process.kill('USR1', Process.pid)` unconditionally after a
115
+ dispatch error (e.g. SQS still failing once the fetcher exhausted its retries)
116
+ - The CLI Runner traps USR1 and turns it into a graceful shutdown, but a host embedding
117
+ `Shoryuken::Launcher` directly has USR1's default disposition, so the whole process - and its
118
+ in-flight workers - was terminated
119
+ - When embedded (no CLI Runner), the failing manager now just stops itself and `Launcher#healthy?`
120
+ reports the failure; the USR1 signal is only sent in server (CLI) mode, preserving the
121
+ supervisor-restart behavior there
122
+
123
+ - Fix: Graceful stop is now bounded by the configured timeout (mensfeld)
124
+ - `Launcher#stop` (the soft shutdown behind USR1/TSTP) called `executor.wait_for_termination` with no
125
+ argument - an unbounded wait - so a single hung worker blocked shutdown forever
126
+ - Both `Launcher#stop` and `Launcher#stop!` now share a `shutdown_executor` helper that waits up to
127
+ `Shoryuken.options[:timeout]` seconds for in-flight workers, then force-kills the executor so the
128
+ process can exit; the graceful stop still waits for workers, just no longer indefinitely
129
+
130
+ - Docs: Correct the `retry_intervals` exponential backoff documentation (mensfeld)
131
+ - The `exponential_backoff?` docstring claimed retries stop ("before giving up") after the last configured
132
+ interval. They do not: once the intervals are exhausted, the last interval is reused for every later
133
+ attempt, and SQS's redrive policy (maxReceiveCount) is what ultimately moves a message to a dead-letter queue
134
+ - Added a spec pinning that far-later attempts keep reusing the last interval
135
+
136
+ - Fix: `CurrentAttributes.persist` no longer drops a class when called once per class (mensfeld)
137
+ - The storage key was derived from the per-call index, so registering classes across separate `persist`
138
+ calls made the third call reuse `cattr_0` and silently overwrite the second class - its attributes were
139
+ then never serialized or restored
140
+ - The key now uses the running registry size, so incremental and single-call registration both yield
141
+ distinct, stable keys (single-call `persist(A, B, C)` keys are unchanged)
142
+
143
+ - Fix: Busy-processor accounting no longer breaks when processor completion raises (mensfeld)
144
+ - `Manager#assign` chained `.then { processor_done }.rescue { processor_done }`, so an exception inside
145
+ `processor_done` (SQS lookups or a polling strategy's `message_processed` callback) ran completion twice
146
+ - The busy counter was decremented twice for one message and drifted negative, inflating `ready` and
147
+ silently breaking the configured concurrency limit for the life of the process
148
+ - Completion now runs in an `ensure` around processing (exactly once), and `processor_done` logs
149
+ instead of leaking exceptions from the FIFO bookkeeping
150
+
151
+ - Fix: Repeated graceful stop no longer deadlocks the process (mensfeld)
152
+ - `Manager#await_dispatching_in_progress` popped a signal queue that received exactly one token,
153
+ so a second `Launcher#stop` blocked forever on an empty queue
154
+ - Operationally this was the TSTP -> USR1 sequence: both signals trigger a graceful stop, leaving
155
+ the process stuck in the signal loop where even TERM/INT were no longer handled (only SIGKILL worked)
156
+ - The dispatch loop now closes the signal queue instead of pushing a token, releasing every pending
157
+ and future waiter; the dispatch chain also releases waiters if the executor rejects a post during
158
+ a racing hard shutdown
159
+
160
+ - Fix: `non_retryable_exceptions` is no longer ignored when `retry_intervals` is also configured (mensfeld)
161
+ - `ExponentialBackoffRetry` swallowed every exception after scheduling a retry, so `NonRetryableException`
162
+ (which sits outside it in the default middleware chain) never saw non-retryable errors - poison messages
163
+ were retried indefinitely (with the last interval repeated) instead of being deleted immediately
164
+ - `ExponentialBackoffRetry` now re-raises exceptions classified as non-retryable so the message gets deleted
165
+ - Exception classification is extracted to `NonRetryableException.non_retryable?` and shared by both middlewares
166
+
167
+ - Enhancement: Use dynamic Ruby warning category opt-in in test helpers (mensfeld)
168
+ - Replace version-gated `Warning[:performance]` with `Warning.categories`-based auto-enablement
169
+ - Automatically enables all non-deprecated, non-experimental warning categories for forward compatibility
170
+ - Applied to both `spec/spec_helper.rb` and `spec/integrations_helper.rb`
171
+
1
172
  ## [7.0.2] - 2026-04-16
2
173
 
3
174
  - Enhancement: Replace LocalStack with ElasticMQ for SQS integration tests (mensfeld)
data/Gemfile.lint.lock CHANGED
@@ -31,11 +31,11 @@ GEM
31
31
  unicode-display_width (3.2.0)
32
32
  unicode-emoji (~> 4.1)
33
33
  unicode-emoji (4.2.0)
34
- yard (0.9.38)
35
- yard-lint (1.4.0)
34
+ yard (0.9.45)
35
+ yard-lint (1.10.2)
36
36
  yard (~> 0.9)
37
37
  zeitwerk (~> 2.6)
38
- zeitwerk (2.7.4)
38
+ zeitwerk (2.8.2)
39
39
 
40
40
  PLATFORMS
41
41
  ruby
@@ -47,6 +47,7 @@ DEPENDENCIES
47
47
 
48
48
  CHECKSUMS
49
49
  ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
50
+ bundler (4.0.17) sha256=214e21431b5665dd2f99df8a5511c6b151d7a72e8015c8b38f8b775b61cbb6c1
50
51
  json (2.18.0) sha256=b10506aee4183f5cf49e0efc48073d7b75843ce3782c68dbeb763351c08fd505
51
52
  language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
52
53
  lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
@@ -61,9 +62,9 @@ CHECKSUMS
61
62
  ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
62
63
  unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
63
64
  unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
64
- yard (0.9.38) sha256=721fb82afb10532aa49860655f6cc2eaa7130889df291b052e1e6b268283010f
65
- yard-lint (1.4.0) sha256=7dd88fbb08fd77cb840bea899d58812817b36d92291b5693dd0eeb3af9f91f0f
66
- zeitwerk (2.7.4) sha256=2bef90f356bdafe9a6c2bd32bcd804f83a4f9b8bc27f3600fff051eb3edcec8b
65
+ yard (0.9.45) sha256=52e211493f7cb8a3ebf7e104a25a1e73937a3103092545d34cb88fafebb3dc51
66
+ yard-lint (1.10.2) sha256=3e595709d088a8cc827e706a4146b447eb6a907ee375d06c11077369d00717f6
67
+ zeitwerk (2.8.2) sha256=7212a61311083c604184b1ea2574b9aa05cd14f855a0841c06985cabe9181d12
67
68
 
68
69
  BUNDLED WITH
69
- 4.0.3
70
+ 4.0.17
data/bin/cli/sqs.rb CHANGED
@@ -12,6 +12,14 @@ module Shoryuken
12
12
  # @see https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/quotas-messages.html
13
13
  MAX_BATCH_SIZE = 1024 * 1024
14
14
 
15
+ # Long-poll wait (seconds) used when draining a queue in find_all, so a
16
+ # short-poll empty batch doesn't end dump/mv prematurely.
17
+ FIND_ALL_WAIT_SECONDS = 1
18
+
19
+ # Number of consecutive empty long-poll batches before find_all concludes
20
+ # the queue is drained.
21
+ FIND_ALL_MAX_EMPTY_BATCHES = 3
22
+
15
23
  namespace :sqs
16
24
  class_option :endpoint, aliases: '-e', type: :string, default: ENV['SHORYUKEN_SQS_ENDPOINT'], desc: 'Endpoint URL'
17
25
 
@@ -146,6 +154,13 @@ module Shoryuken
146
154
  value[:string_value].bytesize
147
155
  elsif value[:binary_value]
148
156
  value[:binary_value].bytesize
157
+ else
158
+ # List-type attributes (string_list_values /
159
+ # binary_list_values) carry no scalar value here and
160
+ # are intentionally not requeued (see
161
+ # normalize_dump_message). Size them as 0 rather than
162
+ # crashing the whole batch with `nil + Integer`.
163
+ 0
149
164
  end
150
165
  name_size + data_type_size + value_size
151
166
  end
@@ -163,6 +178,25 @@ module Shoryuken
163
178
  # @return [Integer] the number of messages received
164
179
  def find_all(url, limit, &block)
165
180
  count = 0
181
+ empty_batches = 0
182
+ # Callers (dump/mv) collect messages and only delete them after this
183
+ # method returns (so a failed dump/mv deletes nothing), which means
184
+ # nothing is removed from the queue during the drain. Two consequences:
185
+ #
186
+ # 1. A large drain can outlast the queue's visibility timeout, at
187
+ # which point already-received messages become visible again and
188
+ # are handed back on a later receive. `seen` maps message id to
189
+ # the first yielded message so those re-reads are not yielded,
190
+ # counted, or dumped/moved twice - but the newest receipt handle
191
+ # is copied onto that message, because SQS only honors the most
192
+ # recently received handle for deletion (an older one can report
193
+ # success yet leave the message in the queue).
194
+ # 2. Because messages stay in flight until the caller deletes them,
195
+ # a queue larger than SQS's in-flight limit (~120k standard /
196
+ # ~20k FIFO) cannot be drained in a single pass: once the limit
197
+ # is reached receive returns empty and find_all stops. No message
198
+ # is lost - re-run dump/mv to drain the remainder.
199
+ seen = {}
166
200
  batch_size = limit > 10 ? 10 : limit
167
201
 
168
202
  loop do
@@ -172,16 +206,46 @@ module Shoryuken
172
206
  messages = sqs.receive_message(
173
207
  queue_url: url,
174
208
  max_number_of_messages: batch_size,
209
+ # Long poll: short polling (the default wait_time_seconds: 0)
210
+ # samples only a subset of SQS hosts and routinely returns an empty
211
+ # batch even when the queue still has messages, which made dump/mv
212
+ # stop early and miss messages on real (distributed) SQS.
213
+ wait_time_seconds: FIND_ALL_WAIT_SECONDS,
175
214
  attribute_names: ['All'],
176
215
  message_attribute_names: ['All']
177
216
  ).messages || []
178
217
 
179
- messages.each(&block)
218
+ # Split first-time messages from re-reads (same id handed back after
219
+ # the visibility timeout lapsed). A batch of only re-reads counts as
220
+ # empty so the drain still terminates.
221
+ fresh = []
222
+ messages.each do |message|
223
+ if (previous = seen[message.message_id])
224
+ # Keep the newest receipt handle on the already-yielded message
225
+ # so the caller's deferred batch_delete targets a handle SQS
226
+ # still accepts.
227
+ previous.receipt_handle = message.receipt_handle
228
+ else
229
+ seen[message.message_id] = message
230
+ fresh << message
231
+ end
232
+ end
180
233
 
181
- count += messages.size
234
+ fresh.each(&block)
235
+
236
+ count += fresh.size
182
237
 
183
238
  break if count >= limit
184
- break if messages.empty?
239
+
240
+ # Even with long polling an occasional empty batch is possible while
241
+ # messages remain, so only give up after several consecutive batches
242
+ # that yielded no new messages.
243
+ if fresh.empty?
244
+ empty_batches += 1
245
+ break if empty_batches >= FIND_ALL_MAX_EMPTY_BATCHES
246
+ else
247
+ empty_batches = 0
248
+ end
185
249
  end
186
250
 
187
251
  count
data/bin/integrations CHANGED
@@ -70,21 +70,23 @@ class IntegrationRunner
70
70
  def run_specs(specs)
71
71
  results = []
72
72
 
73
- specs.each do |spec|
73
+ specs.each_with_index do |spec, idx|
74
+ prefix = "[#{idx + 1}/#{specs.size}]"
75
+ puts "#{prefix} RUNNING #{spec[:name]}"
76
+ $stdout.flush
77
+
74
78
  result = run_spec(spec)
75
79
  results << result
76
80
 
77
- if result[:skipped]
78
- print 'S'
79
- elsif result[:success]
80
- print '.'
81
- else
82
- print 'F'
83
- end
81
+ elapsed = result[:duration] ? format('%.1fs', result[:duration]) : '?'
82
+ status = if result[:skipped] then 'SKIPPED'
83
+ elsif result[:success] then 'OK '
84
+ else 'FAILED '
85
+ end
86
+ puts "#{prefix} #{status} #{spec[:name]} (#{elapsed})"
84
87
  $stdout.flush
85
88
  end
86
89
 
87
- puts
88
90
  results
89
91
  end
90
92
 
@@ -132,34 +134,50 @@ class IntegrationRunner
132
134
  output = []
133
135
  start_time = Time.now
134
136
 
135
- begin
136
- Timeout.timeout(TIMEOUT) do
137
- # Use unbundled env to prevent pollution from previous test runs
138
- # This is especially important after Rails integration tests that use
139
- # bundle install --standalone with different gem versions
140
- Bundler.with_unbundled_env do
141
- IO.popen(env, cmd, chdir: spec[:directory], err: [:child, :out]) do |io|
142
- io.each_line { |line| output << line }
143
- end
137
+ Bundler.with_unbundled_env do
138
+ # Use non-block popen so we control when waitpid runs. The block form
139
+ # calls io.close (which calls waitpid internally) as part of its own
140
+ # ensure, so if the child is still running when Timeout::Error fires,
141
+ # io.close blocks indefinitely before we can rescue the error.
142
+ io = IO.popen(env, cmd, chdir: spec[:directory], err: [:child, :out])
143
+ timed_out = false
144
+
145
+ begin
146
+ Timeout.timeout(TIMEOUT) do
147
+ io.each_line { |line| output << line }
148
+ end
149
+ rescue Timeout::Error
150
+ timed_out = true
151
+ ensure
152
+ # Kill before closing: io.close calls waitpid, which blocks if the
153
+ # child is still alive. Sending SIGKILL first guarantees the child
154
+ # exits promptly so waitpid (inside io.close) returns immediately.
155
+ begin
156
+ Process.kill('KILL', io.pid)
157
+ rescue Errno::ESRCH
158
+ # Child already exited on its own — nothing to kill
144
159
  end
160
+ io.close rescue nil
145
161
  end
146
162
 
147
- {
148
- spec: spec,
149
- success: $?.success?,
150
- exit_code: $?.exitstatus,
151
- duration: Time.now - start_time,
152
- output: output.join
153
- }
154
- rescue Timeout::Error
155
- {
156
- spec: spec,
157
- success: false,
158
- exit_code: -1,
159
- error: 'Timeout',
160
- duration: Time.now - start_time,
161
- output: output.join
162
- }
163
+ if timed_out
164
+ {
165
+ spec: spec,
166
+ success: false,
167
+ exit_code: -1,
168
+ error: 'Timeout',
169
+ duration: Time.now - start_time,
170
+ output: output.join
171
+ }
172
+ else
173
+ {
174
+ spec: spec,
175
+ success: $?.success?,
176
+ exit_code: $?.exitstatus,
177
+ duration: Time.now - start_time,
178
+ output: output.join
179
+ }
180
+ end
163
181
  end
164
182
  end
165
183
 
@@ -2,9 +2,8 @@
2
2
 
3
3
  module Shoryuken
4
4
  module ActiveJob
5
- # Adds an accessor for SQS SendMessage parameters on ActiveJob jobs
6
- # (instances of ActiveJob::Base). Shoryuken ActiveJob queue adapters use
7
- # these parameters when enqueueing jobs; other adapters can ignore them.
5
+ # Adds an accessor for SQS SendMessage parameters on ActiveJob jobs (instances of ActiveJob::Base). Shoryuken ActiveJob
6
+ # queue adapters use these parameters when enqueueing jobs; other adapters can ignore them.
8
7
  module SQSSendMessageParametersAccessor
9
8
  extend ActiveSupport::Concern
10
9
 
@@ -14,14 +13,12 @@ module Shoryuken
14
13
  end
15
14
  end
16
15
 
17
- # Initializes SQS SendMessage parameters on instances of ActiveJob::Base
18
- # to the empty hash, and populates it whenever `#enqueue` is called, such
19
- # as when using ActiveJob::Base.set.
16
+ # Initializes SQS SendMessage parameters on instances of ActiveJob::Base to the empty hash, and populates it whenever
17
+ # `#enqueue` is called, such as when using ActiveJob::Base.set.
20
18
  module SQSSendMessageParametersSupport
21
19
  # Initializes a new ActiveJob instance with empty SQS parameters
22
20
  #
23
- # Uses argument forwarding (...) to properly pass all arguments including
24
- # keyword arguments to the base class.
21
+ # Uses argument forwarding (...) to properly pass all arguments including keyword arguments to the base class.
25
22
  def initialize(...)
26
23
  super(...)
27
24
  self.sqs_send_message_parameters = {}