shoryuken 7.0.2 → 7.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/push.yml +3 -3
  3. data/.github/workflows/specs.yml +32 -5
  4. data/.github/workflows/verify-action-pins.yml +1 -1
  5. data/.ruby-version +1 -1
  6. data/CHANGELOG.md +107 -0
  7. data/bin/cli/sqs.rb +7 -0
  8. data/bin/integrations +52 -34
  9. data/lib/active_job/queue_adapters/shoryuken_adapter.rb +3 -1
  10. data/lib/shoryuken/active_job/current_attributes.rb +35 -7
  11. data/lib/shoryuken/fetcher.rb +7 -1
  12. data/lib/shoryuken/helpers/timer_task.rb +19 -2
  13. data/lib/shoryuken/launcher.rb +21 -5
  14. data/lib/shoryuken/manager.rb +38 -5
  15. data/lib/shoryuken/middleware/server/auto_extend_visibility.rb +32 -2
  16. data/lib/shoryuken/middleware/server/exponential_backoff_retry.rb +8 -3
  17. data/lib/shoryuken/middleware/server/non_retryable_exception.rb +17 -8
  18. data/lib/shoryuken/options.rb +12 -1
  19. data/lib/shoryuken/polling/strict_priority.rb +26 -14
  20. data/lib/shoryuken/polling/weighted_round_robin.rb +41 -27
  21. data/lib/shoryuken/queue.rb +8 -1
  22. data/lib/shoryuken/util.rb +4 -1
  23. data/lib/shoryuken/version.rb +1 -1
  24. data/lib/shoryuken/worker.rb +5 -1
  25. data/lib/shoryuken.rb +2 -0
  26. data/renovate.json +16 -2
  27. data/spec/integration/active_job/current_attributes/cross_job_reset_spec.rb +47 -0
  28. data/spec/integration/active_job/current_attributes/incremental_persist_spec.rb +76 -0
  29. data/spec/integration/active_job/fifo_dedup_opt_out/fifo_dedup_opt_out_spec.rb +67 -0
  30. data/spec/integration/auto_extend_visibility/short_visibility_timeout_spec.rb +52 -0
  31. data/spec/integration/concurrent_processing/processor_accounting_spec.rb +94 -0
  32. data/spec/integration/fifo_ordering/fifo_max_messages_cap_spec.rb +96 -0
  33. data/spec/integration/launcher/double_graceful_stop_spec.rb +71 -0
  34. data/spec/integration/launcher/embedded_dispatch_error_spec.rb +85 -0
  35. data/spec/integration/launcher/global_executor_preserved_spec.rb +76 -0
  36. data/spec/integration/launcher/graceful_stop_timeout_spec.rb +74 -0
  37. data/spec/integration/message_operations/partial_batch_delete_spec.rb +67 -0
  38. data/spec/integration/non_retryable_exception/non_retryable_exception_spec.rb +1 -1
  39. data/spec/integration/non_retryable_exception/with_retry_intervals_spec.rb +115 -0
  40. data/spec/integrations_helper.rb +10 -9
  41. data/spec/lib/shoryuken/fetcher_spec.rb +13 -0
  42. data/spec/lib/shoryuken/helpers/timer_task_spec.rb +24 -0
  43. data/spec/lib/shoryuken/launcher_spec.rb +38 -0
  44. data/spec/lib/shoryuken/manager_spec.rb +99 -0
  45. data/spec/lib/shoryuken/middleware/server/auto_extend_visibility_spec.rb +35 -0
  46. data/spec/lib/shoryuken/middleware/server/exponential_backoff_retry_spec.rb +56 -0
  47. data/spec/lib/shoryuken/polling/strict_priority_spec.rb +25 -0
  48. data/spec/lib/shoryuken/polling/weighted_round_robin_spec.rb +50 -0
  49. data/spec/lib/shoryuken/queue_spec.rb +37 -0
  50. data/spec/lib/shoryuken/util_spec.rb +26 -0
  51. data/spec/shared_examples_for_active_job.rb +18 -0
  52. data/spec/spec_helper.rb +26 -7
  53. metadata +26 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 63763ea11f3377475150b2270c7162fa5cb9a35e39cdd2a534e353837dc3e6ff
4
- data.tar.gz: f265d74ebdb8a087cb658a8f442489523305e7cd551e5fae4ae16f9709f238ae
3
+ metadata.gz: 988d29701fd4b73dea7d965b283ae8d3fccf5b9737a7e3acdfe5cc760ca82cc5
4
+ data.tar.gz: b91a7b40cab94ca17fb432433a81323deba9675a3398678c80db1b3a7aa454fb
5
5
  SHA512:
6
- metadata.gz: 012a20c2c6cf74c8af2be01f046e137f74df8f6ca0746bc3c05f3dfc6eb45d0bf320a4a6493917c8c12371f94dafb0777873eb71d4298f097ecdb1bf9f9b2fd5
7
- data.tar.gz: e257ec56be80a862449937cf882fd2dfb42037c4b9e1bbfe2987463a45e7bd0857a18a94bafd225ecb3e170180a62d59b4e3cd926d863246621d6c5683bce9a1
6
+ metadata.gz: e6bc156e3e5387ad74c4a740cae775f6f633b67c26395a3aac11e51b7cc47771785918e5ec457d3f2317148846ef3ad0b137b5f6e012f3f75aec6536a2eb2deb
7
+ data.tar.gz: 87f0feeae55309458dbd5eab9fea4fbdb83a5689bd526b0e22857cb89f0af7027346f2e1487614965b3208aafaf6167dc7bb2573e160d45cfb54e8133baa09c0
@@ -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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # 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@d45b1a4e94b71acab930e56e79c6aa188764e7f9 # v1.316.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@052cc82692552de3ef2b81fd670e41d13cba8092 # v1.4.0
@@ -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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
17
20
 
18
- - uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0
21
+ - uses: ruby/setup-ruby@d45b1a4e94b71acab930e56e79c6aa188764e7f9 # v1.316.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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # 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@d45b1a4e94b71acab930e56e79c6aa188764e7f9 # v1.316.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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # 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.5
data/CHANGELOG.md CHANGED
@@ -1,3 +1,110 @@
1
+ ## [Unreleased]
2
+
3
+ ## [7.0.3] - 2026-07-10
4
+
5
+ - Feature: `Shoryuken.active_job_fifo_message_deduplication` to opt out of FIFO dedup id generation (mensfeld)
6
+ - For FIFO queues the ActiveJob adapter derives a content-based `message_deduplication_id` from the
7
+ serialized job minus `job_id`/`enqueued_at` (#457 / #750), so two distinct enqueues of the same job
8
+ class and arguments within SQS's 5-minute window silently collapse into one - a "skipped message" trap
9
+ - Set `Shoryuken.active_job_fifo_message_deduplication = false` to stop generating that id, so identical
10
+ jobs are no longer silently dropped (rely on the queue's content-based deduplication or explicit ids)
11
+ - Defaults to `true`, preserving the existing behavior; an explicit `message_deduplication_id` is still honored
12
+
13
+ - Fix: Polling strategies are now thread-safe, and WeightedRoundRobin unpauses processed queues reliably (mensfeld)
14
+ - `message_processed` runs on processor-completion threads (for FIFO queues) while `next_queue`/`messages_found`
15
+ run on the dispatch thread; they mutate the same state with no synchronization, which is benign on MRI (GVL)
16
+ but corrupts state on JRuby/TruffleRuby. Both `WeightedRoundRobin` and `StrictPriority` now serialize access
17
+ with a mutex
18
+ - `WeightedRoundRobin#unpause_queues` only checked the head of the paused list, so a queue marked ready by
19
+ `message_processed` could stay stuck behind an earlier-paused queue; it now unpauses the first expired entry
20
+ anywhere in the list
21
+
22
+ - Fix: `TimerTask#kill` no longer deadlocks on Ruby 3.2 under concurrent callers (mensfeld)
23
+ - `kill` called `@thread.kill` while holding `@mutex`; the timer loop's `ensure` block calls
24
+ `@mutex.synchronize` to clear `@running`, so on Ruby 3.2 (where `Thread#kill` yields the GVL
25
+ to the killed thread for cleanup before returning) both threads waited on each other forever
26
+ - The thread is now killed after the mutex is released, so the ensure block can always acquire it
27
+
28
+ - Fix: Stopping the launcher no longer destroys the process-global IO executor (mensfeld)
29
+ - With no `launcher_executor` configured, `Launcher#executor` fell back to `Concurrent.global_io_executor`,
30
+ and `Launcher#stop`/`#stop!` call `shutdown` (and `kill`) on it
31
+ - Shutting down that process-wide pool broke anything else relying on concurrent-ruby's `:io` pool
32
+ (including Shoryuken's own `ShoryukenConcurrentSendAdapter`) and prevented starting a fresh launcher
33
+ in the same process
34
+ - The launcher now owns a dedicated `Concurrent::CachedThreadPool`, so stopping it leaves the global
35
+ IO executor untouched
36
+
37
+ - Fix: `Queue#delete_messages` now logs every batch-delete failure and returns a robust boolean (mensfeld)
38
+ - It used `failed.any? { |f| logger.error ... }`, which short-circuited after the first failure - so when
39
+ a batch had multiple failures only the first was logged - and only returned truthy because `Logger#error`
40
+ happens to return true (a custom logger returning falsey would have hidden the failure from callers)
41
+ - It now logs each failure and returns `failed.any?`, so `NonRetryableException`/`AutoDelete` reliably see
42
+ that some messages may remain and need reprocessing
43
+
44
+ - Fix: Lifecycle events fired in reverse no longer alternate handler order (mensfeld)
45
+ - `Util#fire_event` reversed the stored handler array in place with `reverse!`, so an event fired more
46
+ than once with `reverse: true` (e.g. `:shutdown` via `stop` then `stop!`) flipped order each time
47
+ - It now reverses a copy, leaving the stored handler order untouched
48
+
49
+ - Fix: A fatal dispatch error no longer hard-kills an embedded host process (mensfeld)
50
+ - `Manager#handle_dispatch_error` sent `Process.kill('USR1', Process.pid)` unconditionally after a
51
+ dispatch error (e.g. SQS still failing once the fetcher exhausted its retries)
52
+ - The CLI Runner traps USR1 and turns it into a graceful shutdown, but a host embedding
53
+ `Shoryuken::Launcher` directly has USR1's default disposition, so the whole process - and its
54
+ in-flight workers - was terminated
55
+ - When embedded (no CLI Runner), the failing manager now just stops itself and `Launcher#healthy?`
56
+ reports the failure; the USR1 signal is only sent in server (CLI) mode, preserving the
57
+ supervisor-restart behavior there
58
+
59
+ - Fix: Graceful stop is now bounded by the configured timeout (mensfeld)
60
+ - `Launcher#stop` (the soft shutdown behind USR1/TSTP) called `executor.wait_for_termination` with no
61
+ argument - an unbounded wait - so a single hung worker blocked shutdown forever
62
+ - Both `Launcher#stop` and `Launcher#stop!` now share a `shutdown_executor` helper that waits up to
63
+ `Shoryuken.options[:timeout]` seconds for in-flight workers, then force-kills the executor so the
64
+ process can exit; the graceful stop still waits for workers, just no longer indefinitely
65
+
66
+ - Docs: Correct the `retry_intervals` exponential backoff documentation (mensfeld)
67
+ - The `exponential_backoff?` docstring claimed retries stop ("before giving up") after the last configured
68
+ interval. They do not: once the intervals are exhausted, the last interval is reused for every later
69
+ attempt, and SQS's redrive policy (maxReceiveCount) is what ultimately moves a message to a dead-letter queue
70
+ - Added a spec pinning that far-later attempts keep reusing the last interval
71
+
72
+ - Fix: `CurrentAttributes.persist` no longer drops a class when called once per class (mensfeld)
73
+ - The storage key was derived from the per-call index, so registering classes across separate `persist`
74
+ calls made the third call reuse `cattr_0` and silently overwrite the second class - its attributes were
75
+ then never serialized or restored
76
+ - The key now uses the running registry size, so incremental and single-call registration both yield
77
+ distinct, stable keys (single-call `persist(A, B, C)` keys are unchanged)
78
+
79
+ - Fix: Busy-processor accounting no longer breaks when processor completion raises (mensfeld)
80
+ - `Manager#assign` chained `.then { processor_done }.rescue { processor_done }`, so an exception inside
81
+ `processor_done` (SQS lookups or a polling strategy's `message_processed` callback) ran completion twice
82
+ - The busy counter was decremented twice for one message and drifted negative, inflating `ready` and
83
+ silently breaking the configured concurrency limit for the life of the process
84
+ - Completion now runs in an `ensure` around processing (exactly once), and `processor_done` logs
85
+ instead of leaking exceptions from the FIFO bookkeeping
86
+
87
+ - Fix: Repeated graceful stop no longer deadlocks the process (mensfeld)
88
+ - `Manager#await_dispatching_in_progress` popped a signal queue that received exactly one token,
89
+ so a second `Launcher#stop` blocked forever on an empty queue
90
+ - Operationally this was the TSTP -> USR1 sequence: both signals trigger a graceful stop, leaving
91
+ the process stuck in the signal loop where even TERM/INT were no longer handled (only SIGKILL worked)
92
+ - The dispatch loop now closes the signal queue instead of pushing a token, releasing every pending
93
+ and future waiter; the dispatch chain also releases waiters if the executor rejects a post during
94
+ a racing hard shutdown
95
+
96
+ - Fix: `non_retryable_exceptions` is no longer ignored when `retry_intervals` is also configured (mensfeld)
97
+ - `ExponentialBackoffRetry` swallowed every exception after scheduling a retry, so `NonRetryableException`
98
+ (which sits outside it in the default middleware chain) never saw non-retryable errors - poison messages
99
+ were retried indefinitely (with the last interval repeated) instead of being deleted immediately
100
+ - `ExponentialBackoffRetry` now re-raises exceptions classified as non-retryable so the message gets deleted
101
+ - Exception classification is extracted to `NonRetryableException.non_retryable?` and shared by both middlewares
102
+
103
+ - Enhancement: Use dynamic Ruby warning category opt-in in test helpers (mensfeld)
104
+ - Replace version-gated `Warning[:performance]` with `Warning.categories`-based auto-enablement
105
+ - Automatically enables all non-deprecated, non-experimental warning categories for forward compatibility
106
+ - Applied to both `spec/spec_helper.rb` and `spec/integrations_helper.rb`
107
+
1
108
  ## [7.0.2] - 2026-04-16
2
109
 
3
110
  - Enhancement: Replace LocalStack with ElasticMQ for SQS integration tests (mensfeld)
data/bin/cli/sqs.rb CHANGED
@@ -146,6 +146,13 @@ module Shoryuken
146
146
  value[:string_value].bytesize
147
147
  elsif value[:binary_value]
148
148
  value[:binary_value].bytesize
149
+ else
150
+ # List-type attributes (string_list_values /
151
+ # binary_list_values) carry no scalar value here and
152
+ # are intentionally not requeued (see
153
+ # normalize_dump_message). Size them as 0 rather than
154
+ # crashing the whole batch with `nil + Integer`.
155
+ 0
149
156
  end
150
157
  name_size + data_type_size + value_size
151
158
  end
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
 
@@ -177,9 +177,11 @@ module ActiveJob
177
177
  message_attributes: attributes.merge(MESSAGE_ATTRIBUTES)
178
178
  }
179
179
 
180
- if queue.fifo?
180
+ if queue.fifo? && Shoryuken.active_job_fifo_message_deduplication?
181
181
  # See https://github.com/ruby-shoryuken/shoryuken/issues/457 and
182
182
  # https://github.com/ruby-shoryuken/shoryuken/pull/750#issuecomment-1781317929
183
+ # Disable via Shoryuken.active_job_fifo_message_deduplication = false when distinct
184
+ # enqueues of the same job class and arguments must not be silently deduplicated.
183
185
  msg[:message_deduplication_id] = Digest::SHA256.hexdigest(
184
186
  JSON.dump(body.except('job_id', 'enqueued_at'))
185
187
  )
@@ -11,7 +11,12 @@ module Shoryuken
11
11
  # This ensures that request-scoped context (like current user, tenant, locale)
12
12
  # automatically flows from the code that enqueues a job to the job's execution.
13
13
  #
14
- # Based on Sidekiq's approach to persisting current attributes.
14
+ # Based on Sidekiq's approach to persisting current attributes, with one
15
+ # deliberate difference in cleanup: Sidekiq only touches the classes carried
16
+ # by a job and leaves the general reset to the Rails executor it runs jobs
17
+ # inside. Shoryuken does not run jobs inside that executor by default, so the
18
+ # loader resets every registered class after each job itself - see the note
19
+ # on {Loading#perform}.
15
20
  #
16
21
  # @example Setup in initializer
17
22
  # require 'shoryuken/active_job/current_attributes'
@@ -58,8 +63,12 @@ module Shoryuken
58
63
  def persist(*klasses)
59
64
  @cattrs ||= {}
60
65
 
61
- klasses.flatten.each_with_index do |klass, idx|
62
- key = @cattrs.empty? ? 'cattr' : "cattr_#{idx}"
66
+ klasses.flatten.each do |klass|
67
+ # Key off the running registry size, not the per-call index, so that
68
+ # registering classes across separate persist calls still produces
69
+ # distinct keys (a per-call index restarts at 0 each call and would
70
+ # overwrite earlier registrations).
71
+ key = @cattrs.empty? ? 'cattr' : "cattr_#{@cattrs.size}"
63
72
  @cattrs[key] = klass.to_s
64
73
  end
65
74
 
@@ -109,13 +118,10 @@ module Shoryuken
109
118
  # @param hash [Hash] the deserialized job data
110
119
  # @return [void]
111
120
  def perform(sqs_msg, hash)
112
- klasses_to_reset = []
113
-
114
121
  CurrentAttributes.cattrs&.each do |key, klass_name|
115
122
  next unless hash.key?(key)
116
123
 
117
124
  klass = klass_name.constantize
118
- klasses_to_reset << klass
119
125
 
120
126
  begin
121
127
  attrs = Serializer.deserialize(hash[key])
@@ -131,7 +137,29 @@ module Shoryuken
131
137
 
132
138
  super
133
139
  ensure
134
- klasses_to_reset.each(&:reset)
140
+ # Reset every registered CurrentAttributes class after the job - not
141
+ # only the ones whose key was in this message.
142
+ #
143
+ # Why unconditional (and why this differs from Sidekiq): Sidekiq's
144
+ # loader only touches classes present in the job and relies on the
145
+ # Rails executor - which it runs every job inside - to reset all
146
+ # CurrentAttributes between units of work. Shoryuken has no such safety
147
+ # net: it wraps a job in the reloader/executor only when
148
+ # `enable_reloading` is set, which is off by default, so nothing else
149
+ # clears CurrentAttributes between jobs.
150
+ #
151
+ # A blanket reset here is therefore the only thing guaranteeing a clean
152
+ # thread. Resetting only the present keys leaks whenever a value ends up
153
+ # set during a job whose message carried no cattr key - e.g. the worker
154
+ # (or code it calls) writes to Current, on a keyless message (empty
155
+ # context at enqueue, a different producer, or persist configured after
156
+ # the message was queued). CurrentAttributes are thread-local and the
157
+ # pool reuses threads, so that value would surface in the next job.
158
+ CurrentAttributes.cattrs&.each_value do |klass_name|
159
+ klass_name.constantize.reset
160
+ rescue => e
161
+ Shoryuken.logger.warn("Failed to reset CurrentAttributes #{klass_name}: #{e.message}")
162
+ end
135
163
  end
136
164
  end
137
165
  end
@@ -72,12 +72,18 @@ module Shoryuken
72
72
 
73
73
  shoryuken_queue = Shoryuken::Client.queues(queue.name)
74
74
 
75
- options[:max_number_of_messages] = max_number_of_messages(shoryuken_queue, limit, options)
76
75
  options[:message_attribute_names] = %w[All]
77
76
  options[:attribute_names] = %w[All]
78
77
 
78
+ # Merge per-queue options BEFORE computing the cap so the FIFO
79
+ # one-at-a-time guard (and FETCH_LIMIT) always win. Computing the cap last
80
+ # means a queue option of max_number_of_messages can only lower the count,
81
+ # never raise a non-batch FIFO queue above 1 - which would let SQS return
82
+ # several messages from the same group and break ordering.
79
83
  options.merge!(queue.options)
80
84
 
85
+ options[:max_number_of_messages] = max_number_of_messages(shoryuken_queue, limit, options)
86
+
81
87
  shoryuken_queue.receive_messages(options)
82
88
  end
83
89
 
@@ -42,14 +42,21 @@ module Shoryuken
42
42
  #
43
43
  # @return [Boolean] true if killed, false if already killed
44
44
  def kill
45
+ thread_to_kill = nil
46
+
45
47
  @mutex.synchronize do
46
48
  return false if @killed
47
49
 
48
50
  @killed = true
49
51
  @running = false
50
-
51
- @thread.kill if @thread&.alive?
52
+ thread_to_kill = @thread
52
53
  end
54
+
55
+ # Kill the thread AFTER releasing the mutex. The timer loop's ensure
56
+ # block calls @mutex.synchronize to clear @running; killing the thread
57
+ # while holding that mutex deadlocks on Ruby 3.2, where Thread#kill
58
+ # yields the GVL to the killed thread for cleanup before returning.
59
+ thread_to_kill&.kill if thread_to_kill&.alive?
53
60
  true
54
61
  end
55
62
 
@@ -59,6 +66,16 @@ module Shoryuken
59
66
  #
60
67
  # @return [void]
61
68
  def run_timer_loop
69
+ # The timer thread inherits the priority of the thread that called
70
+ # #execute. Shoryuken runs workers at a lowered priority
71
+ # (Shoryuken.thread_priority, default -1) and starts the
72
+ # auto-visibility-extension timer from inside that worker thread, so the
73
+ # timer would otherwise inherit -1. A latency-sensitive timer must not
74
+ # run below normal priority: under CPU contention a delayed extension can
75
+ # miss the visibility timeout and let the message be redelivered (double
76
+ # processed). Reset to normal priority.
77
+ Thread.current.priority = 0
78
+
62
79
  until @killed
63
80
  sleep(@execution_interval)
64
81
  break if @killed
@@ -43,8 +43,7 @@ module Shoryuken
43
43
  # Don't await here so the timeout below is not delayed
44
44
  stop_new_dispatching
45
45
 
46
- executor.shutdown
47
- executor.kill unless executor.wait_for_termination(Shoryuken.options[:timeout])
46
+ shutdown_executor
48
47
 
49
48
  fire_event(:stopped)
50
49
  end
@@ -61,8 +60,7 @@ module Shoryuken
61
60
  stop_new_dispatching
62
61
  await_dispatching_in_progress
63
62
 
64
- executor.shutdown
65
- executor.wait_for_termination
63
+ shutdown_executor
66
64
 
67
65
  fire_event(:stopped)
68
66
  end
@@ -93,11 +91,29 @@ module Shoryuken
93
91
  @managers.each(&:await_dispatching_in_progress)
94
92
  end
95
93
 
94
+ # Shuts the executor down, giving in-flight workers up to the configured
95
+ # timeout to finish before force-killing them so the process can exit.
96
+ # Used by both the graceful ({#stop}) and immediate ({#stop!}) shutdowns:
97
+ # a graceful stop still waits for workers, but must not block forever on a
98
+ # hung one.
99
+ #
100
+ # @return [void]
101
+ def shutdown_executor
102
+ executor.shutdown
103
+ executor.kill unless executor.wait_for_termination(Shoryuken.options[:timeout])
104
+ end
105
+
96
106
  # Returns the executor for running async operations
97
107
  #
108
+ # Owns a dedicated executor rather than borrowing Concurrent.global_io_executor:
109
+ # {#stop} and {#stop!} shut down and kill this executor, and destroying the
110
+ # process-global pool would break anything else relying on it (including
111
+ # Shoryuken's own ShoryukenConcurrentSendAdapter) and prevent a fresh launcher
112
+ # from starting in the same process.
113
+ #
98
114
  # @return [Concurrent::ExecutorService] the executor service
99
115
  def executor
100
- @_executor ||= Shoryuken.launcher_executor || Concurrent.global_io_executor
116
+ @_executor ||= Shoryuken.launcher_executor || Concurrent::CachedThreadPool.new(auto_terminate: true)
101
117
  end
102
118
 
103
119
  # Starts all managers in parallel futures
@@ -57,6 +57,9 @@ module Shoryuken
57
57
  # There might still be a dispatching on-going, as the response from SQS could take some time
58
58
  # We don't want to stop the process before processing incoming messages, as they would stay "in-flight" for some time on SQS
59
59
  # We use a queue, as the dispatch_loop is running on another thread, and this is a efficient way of communicating between threads.
60
+ # The dispatch loop closes the queue when it observes the stop flag; pop on a closed queue
61
+ # returns immediately, so this stays safe when stop is requested more than once
62
+ # (e.g. TSTP followed by USR1, which both trigger a graceful stop).
60
63
  @dispatching_release_signal.pop
61
64
  end
62
65
 
@@ -74,11 +77,23 @@ module Shoryuken
74
77
  # @return [void]
75
78
  def dispatch_loop
76
79
  if @stop_new_dispatching.true? || !running?
77
- @dispatching_release_signal << 1
80
+ # Close (instead of push) so every pending and future
81
+ # await_dispatching_in_progress call returns, not just the first one
82
+ @dispatching_release_signal.close
78
83
  return
79
84
  end
80
85
 
81
86
  @executor.post { dispatch }
87
+ rescue Concurrent::RejectedExecutionError
88
+ # The executor was shut down between the running? check and the post
89
+ # (e.g. a hard stop racing the dispatch loop); release any waiters as
90
+ # the dispatch chain ends here
91
+ @dispatching_release_signal.close
92
+ rescue StandardError
93
+ # Unexpected error from post (e.g. ThreadError if the OS thread limit is
94
+ # hit); the dispatch chain ends here so release waiters before re-raising
95
+ @dispatching_release_signal.close
96
+ raise
82
97
  end
83
98
 
84
99
  # Dispatches messages from a queue
@@ -129,6 +144,12 @@ module Shoryuken
129
144
  return unless @polling_strategy.respond_to?(:message_processed)
130
145
 
131
146
  @polling_strategy.message_processed(queue)
147
+ rescue => e
148
+ # Swallow (but log) failures from the SQS lookups or the strategy callback:
149
+ # the busy counter was already decremented above and the caller's ensure
150
+ # must not run completion twice
151
+ logger.error { "Processor completion failed for #{queue}: #{e.message}" }
152
+ logger.debug { e.backtrace.join("\n") } unless e.backtrace.nil?
132
153
  end
133
154
 
134
155
  # Assigns a message to a processor
@@ -144,6 +165,11 @@ module Shoryuken
144
165
  @busy_processors.increment
145
166
  fire_utilization_update_event
146
167
 
168
+ # Completion runs in an ensure so it executes exactly once whether
169
+ # processing succeeds or raises. The previous `.then { processor_done }
170
+ # .rescue { processor_done }` chain ran completion twice when
171
+ # processor_done itself raised, double decrementing the busy counter
172
+ # and silently breaking the concurrency limit
147
173
  Concurrent::Promise
148
174
  .execute(executor: @executor) do
149
175
  original_priority = Thread.current.priority
@@ -152,10 +178,9 @@ module Shoryuken
152
178
  Processor.process(queue_name, sqs_msg)
153
179
  ensure
154
180
  Thread.current.priority = original_priority
181
+ processor_done(queue_name)
155
182
  end
156
183
  end
157
- .then { processor_done(queue_name) }
158
- .rescue { processor_done(queue_name) }
159
184
  end
160
185
 
161
186
  # Dispatches a batch of messages from a queue
@@ -208,9 +233,17 @@ module Shoryuken
208
233
  logger.error { "Manager failed: #{ex.message}" }
209
234
  logger.error { ex.backtrace.join("\n") } unless ex.backtrace.nil?
210
235
 
211
- Process.kill('USR1', Process.pid)
212
-
236
+ # Stop this manager first so Launcher#healthy? surfaces the failure to
237
+ # whoever is supervising us (the CLI Runner, or an embedding application).
213
238
  @running.make_false
239
+
240
+ # In server (CLI) mode the Runner traps USR1 and turns it into a graceful
241
+ # shutdown of the whole process, so a process supervisor can restart us.
242
+ # When embedded (no Runner), USR1 keeps its default disposition and would
243
+ # terminate the host process - and any in-flight workers - so we must not
244
+ # send it. The stopped manager above is enough for Launcher#healthy? to
245
+ # report the failure to the embedding application.
246
+ Process.kill('USR1', Process.pid) if Shoryuken.server?
214
247
  end
215
248
 
216
249
  # Fires a utilization update event