shoryuken 7.0.0.alpha2 → 7.0.0.rc1

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.
@@ -1,72 +1,156 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Shoryuken
4
- # Middleware is code configured to run before/after
5
- # a message is processed. It is patterned after Rack
6
- # middleware. Middleware exists for the server
7
- # side (when jobs are actually processed).
8
- #
9
- # To modify middleware for the server, just call
10
- # with another block:
11
- #
12
- # Shoryuken.configure_server do |config|
13
- # config.server_middleware do |chain|
14
- # chain.add MyServerHook
15
- # chain.remove ActiveRecord
4
+ # Middleware provides a way to wrap message processing with custom logic,
5
+ # similar to Rack middleware in web applications. Middleware runs on the server
6
+ # side and can perform setup, teardown, error handling, and monitoring around
7
+ # job execution.
8
+ #
9
+ # Middleware classes must implement a `call` method that accepts the worker instance,
10
+ # queue name, and SQS message, and must yield to continue the middleware chain.
11
+ #
12
+ # ## Global Middleware Configuration
13
+ #
14
+ # Configure middleware globally for all workers:
15
+ #
16
+ # Shoryuken.configure_server do |config|
17
+ # config.server_middleware do |chain|
18
+ # chain.add MyServerHook
19
+ # chain.remove Shoryuken::Middleware::Server::ActiveRecord
20
+ # end
16
21
  # end
17
- # end
18
22
  #
19
- # To insert immediately preceding another entry:
23
+ # ## Per-Worker Middleware Configuration
24
+ #
25
+ # Configure middleware for specific workers:
20
26
  #
21
- # Shoryuken.configure_server do |config|
22
- # config.server_middleware do |chain|
23
- # chain.insert_before ActiveRecord, MyServerHook
27
+ # class MyWorker
28
+ # include Shoryuken::Worker
29
+ #
30
+ # server_middleware do |chain|
31
+ # chain.add MyWorkerSpecificMiddleware
32
+ # end
24
33
  # end
25
- # end
26
34
  #
27
- # To insert immediately after another entry:
35
+ # ## Middleware Ordering
36
+ #
37
+ # Insert middleware at specific positions in the chain:
38
+ #
39
+ # # Insert before existing middleware
40
+ # chain.insert_before Shoryuken::Middleware::Server::ActiveRecord, MyDatabaseSetup
41
+ #
42
+ # # Insert after existing middleware
43
+ # chain.insert_after Shoryuken::Middleware::Server::Timing, MyMetricsCollector
28
44
  #
29
- # Shoryuken.configure_server do |config|
30
- # config.server_middleware do |chain|
31
- # chain.insert_after ActiveRecord, MyServerHook
45
+ # # Add to beginning of chain
46
+ # chain.prepend MyFirstMiddleware
47
+ #
48
+ # ## Example Middleware Implementations
49
+ #
50
+ # # Basic logging middleware
51
+ # class LoggingMiddleware
52
+ # def call(worker_instance, queue, sqs_msg, body)
53
+ # puts "Processing #{sqs_msg.message_id} on #{queue}"
54
+ # start_time = Time.now
55
+ # yield
56
+ # puts "Completed in #{Time.now - start_time}s"
57
+ # end
32
58
  # end
33
- # end
34
59
  #
35
- # This is an example of a minimal server middleware:
60
+ # # Error reporting middleware
61
+ # class ErrorReportingMiddleware
62
+ # def call(worker_instance, queue, sqs_msg, body)
63
+ # yield
64
+ # rescue => error
65
+ # ErrorReporter.notify(error, {
66
+ # worker: worker_instance.class.name,
67
+ # queue: queue,
68
+ # message_id: sqs_msg.message_id
69
+ # })
70
+ # raise
71
+ # end
72
+ # end
36
73
  #
37
- # class MyServerHook
38
- # def call(worker_instance, queue, sqs_msg)
39
- # puts 'Before work'
40
- # yield
41
- # puts 'After work'
74
+ # # Performance monitoring middleware
75
+ # class MetricsMiddleware
76
+ # def call(worker_instance, queue, sqs_msg, body)
77
+ # start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
78
+ # yield
79
+ # duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
80
+ # StatsD.timing("shoryuken.#{worker_instance.class.name.underscore}.duration", duration)
81
+ # end
42
82
  # end
43
- # end
44
83
  #
84
+ # @see Shoryuken::Middleware::Chain Middleware chain management
85
+ # @see https://github.com/ruby-shoryuken/shoryuken/wiki/Middleware Comprehensive middleware guide
45
86
  module Middleware
87
+ # Manages a chain of middleware classes that will be instantiated and invoked
88
+ # in sequence around message processing. Provides methods for adding, removing,
89
+ # and reordering middleware.
46
90
  class Chain
91
+ # @return [Array<Entry>] The ordered list of middleware entries
47
92
  attr_reader :entries
48
93
 
94
+ # Creates a new middleware chain.
95
+ #
96
+ # @yield [Chain] The chain instance for configuration
97
+ # @example Creating and configuring a chain
98
+ # chain = Shoryuken::Middleware::Chain.new do |c|
99
+ # c.add MyMiddleware
100
+ # c.add AnotherMiddleware, option: 'value'
101
+ # end
49
102
  def initialize
50
103
  @entries = []
51
104
  yield self if block_given?
52
105
  end
53
106
 
107
+ # Creates a copy of this middleware chain.
108
+ #
109
+ # @return [Chain] A new chain with the same middleware entries
54
110
  def dup
55
111
  self.class.new.tap { |new_chain| new_chain.entries.replace(entries) }
56
112
  end
57
113
 
114
+ # Removes all instances of the specified middleware class from the chain.
115
+ #
116
+ # @param klass [Class] The middleware class to remove
117
+ # @return [Array<Entry>] The removed entries
118
+ # @example Removing ActiveRecord middleware
119
+ # chain.remove Shoryuken::Middleware::Server::ActiveRecord
58
120
  def remove(klass)
59
121
  entries.delete_if { |entry| entry.klass == klass }
60
122
  end
61
123
 
124
+ # Adds middleware to the end of the chain. Does nothing if the middleware
125
+ # class is already present in the chain.
126
+ #
127
+ # @param klass [Class] The middleware class to add
128
+ # @param args [Array] Arguments to pass to the middleware constructor
129
+ # @example Adding middleware with arguments
130
+ # chain.add MyMiddleware, timeout: 30, retries: 3
62
131
  def add(klass, *args)
63
132
  entries << Entry.new(klass, *args) unless exists?(klass)
64
133
  end
65
134
 
135
+ # Adds middleware to the beginning of the chain. Does nothing if the middleware
136
+ # class is already present in the chain.
137
+ #
138
+ # @param klass [Class] The middleware class to prepend
139
+ # @param args [Array] Arguments to pass to the middleware constructor
140
+ # @example Adding middleware to run first
141
+ # chain.prepend AuthenticationMiddleware
66
142
  def prepend(klass, *args)
67
143
  entries.insert(0, Entry.new(klass, *args)) unless exists?(klass)
68
144
  end
69
145
 
146
+ # Inserts middleware immediately before another middleware class.
147
+ # If the new middleware already exists, it's moved to the new position.
148
+ #
149
+ # @param oldklass [Class] The existing middleware to insert before
150
+ # @param newklass [Class] The middleware class to insert
151
+ # @param args [Array] Arguments to pass to the middleware constructor
152
+ # @example Insert database setup before ActiveRecord middleware
153
+ # chain.insert_before Shoryuken::Middleware::Server::ActiveRecord, DatabaseSetup
70
154
  def insert_before(oldklass, newklass, *args)
71
155
  i = entries.index { |entry| entry.klass == newklass }
72
156
  new_entry = i.nil? ? Entry.new(newklass, *args) : entries.delete_at(i)
@@ -74,6 +158,14 @@ module Shoryuken
74
158
  entries.insert(i, new_entry)
75
159
  end
76
160
 
161
+ # Inserts middleware immediately after another middleware class.
162
+ # If the new middleware already exists, it's moved to the new position.
163
+ #
164
+ # @param oldklass [Class] The existing middleware to insert after
165
+ # @param newklass [Class] The middleware class to insert
166
+ # @param args [Array] Arguments to pass to the middleware constructor
167
+ # @example Insert metrics collection after timing middleware
168
+ # chain.insert_after Shoryuken::Middleware::Server::Timing, MetricsCollector
77
169
  def insert_after(oldklass, newklass, *args)
78
170
  i = entries.index { |entry| entry.klass == newklass }
79
171
  new_entry = i.nil? ? Entry.new(newklass, *args) : entries.delete_at(i)
@@ -81,18 +173,34 @@ module Shoryuken
81
173
  entries.insert(i + 1, new_entry)
82
174
  end
83
175
 
176
+ # Checks if a middleware class is already in the chain.
177
+ #
178
+ # @param klass [Class] The middleware class to check for
179
+ # @return [Boolean] True if the middleware is in the chain
84
180
  def exists?(klass)
85
181
  entries.any? { |entry| entry.klass == klass }
86
182
  end
87
183
 
184
+ # Creates instances of all middleware classes in the chain.
185
+ #
186
+ # @return [Array] Array of middleware instances
88
187
  def retrieve
89
188
  entries.map(&:make_new)
90
189
  end
91
190
 
191
+ # Removes all middleware from the chain.
192
+ #
193
+ # @return [Array] Empty array
92
194
  def clear
93
195
  entries.clear
94
196
  end
95
197
 
198
+ # Invokes the middleware chain with the given arguments.
199
+ # Each middleware's call method will be invoked in sequence,
200
+ # with control passed through yielding.
201
+ #
202
+ # @param args [Array] Arguments to pass to each middleware
203
+ # @yield The final action to perform after all middleware
96
204
  def invoke(*args, &final_action)
97
205
  chain = retrieve.dup
98
206
  traverse_chain = lambda do
@@ -105,18 +213,5 @@ module Shoryuken
105
213
  traverse_chain.call
106
214
  end
107
215
  end
108
-
109
- class Entry
110
- attr_reader :klass
111
-
112
- def initialize(klass, *args)
113
- @klass = klass
114
- @args = args
115
- end
116
-
117
- def make_new
118
- @klass.new(*@args)
119
- end
120
- end
121
216
  end
122
217
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shoryuken
4
+ module Middleware
5
+ # Represents an entry in a middleware chain, storing the middleware class
6
+ # and any arguments needed for its instantiation.
7
+ #
8
+ # @api private
9
+ class Entry
10
+ # @return [Class] The middleware class this entry represents
11
+ attr_reader :klass
12
+
13
+ # Creates a new middleware entry.
14
+ #
15
+ # @param klass [Class] The middleware class
16
+ # @param args [Array] Arguments to pass to the middleware constructor
17
+ def initialize(klass, *args)
18
+ @klass = klass
19
+ @args = args
20
+ end
21
+
22
+ # Creates a new instance of the middleware class with the stored arguments.
23
+ #
24
+ # @return [Object] A new instance of the middleware class
25
+ def make_new
26
+ @klass.new(*@args)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -30,7 +30,7 @@ module Shoryuken
30
30
  def auto_extend(_worker, queue, sqs_msg, _body)
31
31
  queue_visibility_timeout = Shoryuken::Client.queues(queue).visibility_timeout
32
32
 
33
- Concurrent::TimerTask.new(execution_interval: queue_visibility_timeout - EXTEND_UPFRONT_SECONDS) do
33
+ Shoryuken::Helpers::TimerTask.new(execution_interval: queue_visibility_timeout - EXTEND_UPFRONT_SECONDS) do
34
34
  logger.debug do
35
35
  "Extending message #{queue}/#{sqs_msg.message_id} visibility timeout by #{queue_visibility_timeout}s"
36
36
  end
@@ -16,6 +16,9 @@ module Shoryuken
16
16
  include Util
17
17
  include Singleton
18
18
 
19
+ # @return [Shoryuken::Launcher, nil] the launcher instance, or nil if not yet initialized
20
+ attr_reader :launcher
21
+
19
22
  def run(options)
20
23
  self_read, self_write = IO.pipe
21
24
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Shoryuken
4
- VERSION = '7.0.0.alpha2'
4
+ VERSION = '7.0.0.rc1'
5
5
  end
@@ -1,6 +1,46 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Shoryuken
4
+ # Worker module provides the core functionality for creating Shoryuken workers
5
+ # that process messages from Amazon SQS queues.
6
+ #
7
+ # Including this module in a class provides methods for configuring queue processing,
8
+ # enqueueing jobs, and setting up middleware. Workers can be configured for different
9
+ # processing patterns including single message processing, batch processing, and
10
+ # various retry and visibility timeout strategies.
11
+ #
12
+ # @example Basic worker implementation
13
+ # class EmailWorker
14
+ # include Shoryuken::Worker
15
+ # shoryuken_options queue: 'emails'
16
+ #
17
+ # def perform(sqs_msg, body)
18
+ # send_email(body['recipient'], body['subject'], body['content'])
19
+ # end
20
+ # end
21
+ #
22
+ # @example Advanced worker with all options
23
+ # class AdvancedWorker
24
+ # include Shoryuken::Worker
25
+ #
26
+ # shoryuken_options queue: 'advanced_queue',
27
+ # batch: false,
28
+ # auto_delete: true,
29
+ # auto_visibility_timeout: true,
30
+ # retry_intervals: [1, 5, 25, 125, 625]
31
+ #
32
+ # server_middleware do |chain|
33
+ # chain.add MyCustomMiddleware
34
+ # end
35
+ #
36
+ # def perform(sqs_msg, body)
37
+ # # Worker implementation
38
+ # end
39
+ # end
40
+ #
41
+ # @see ClassMethods#shoryuken_options Primary configuration method
42
+ # @see ClassMethods#perform_async For enqueueing jobs
43
+ # @see https://github.com/ruby-shoryuken/shoryuken/wiki/Workers Comprehensive worker documentation
4
44
  module Worker
5
45
  def self.included(base)
6
46
  base.extend(ClassMethods)
@@ -8,35 +48,166 @@ module Shoryuken
8
48
  end
9
49
 
10
50
  module ClassMethods
51
+ # Enqueues a job to be processed asynchronously by a Shoryuken worker.
52
+ #
53
+ # @param body [Object] The job payload that will be passed to the worker's perform method
54
+ # @param options [Hash] Additional options for job enqueueing
55
+ # @option options [String] :message_group_id FIFO queue group ID for message ordering
56
+ # @option options [String] :message_deduplication_id FIFO queue deduplication ID
57
+ # @option options [Hash] :message_attributes Custom SQS message attributes
58
+ # @return [String] The message ID of the enqueued job
59
+ #
60
+ # @example Basic job enqueueing
61
+ # MyWorker.perform_async({ user_id: 123, action: 'send_email' })
62
+ #
63
+ # @example FIFO queue with ordering
64
+ # MyWorker.perform_async(data, message_group_id: 'user_123')
11
65
  def perform_async(body, options = {})
12
66
  Shoryuken.worker_executor.perform_async(self, body, options)
13
67
  end
14
68
 
69
+ # Enqueues a job to be processed after a specified time interval.
70
+ #
71
+ # @param interval [Integer, ActiveSupport::Duration] Delay in seconds, or duration object
72
+ # @param body [Object] The job payload that will be passed to the worker's perform method
73
+ # @param options [Hash] Additional options for job enqueueing (see {#perform_async})
74
+ # @return [String] The message ID of the enqueued job
75
+ #
76
+ # @example Delay job by 5 minutes
77
+ # MyWorker.perform_in(5.minutes, { user_id: 123 })
78
+ #
79
+ # @example Delay job by specific number of seconds
80
+ # MyWorker.perform_in(300, { user_id: 123 })
15
81
  def perform_in(interval, body, options = {})
16
82
  Shoryuken.worker_executor.perform_in(self, interval, body, options)
17
83
  end
18
84
 
19
85
  alias_method :perform_at, :perform_in
20
86
 
87
+ # Configures server-side middleware chain for this worker class.
88
+ # Middleware runs before and after job processing, similar to Rack middleware.
89
+ #
90
+ # @yield [Shoryuken::Middleware::Chain] The middleware chain for configuration
91
+ # @return [Shoryuken::Middleware::Chain] The configured middleware chain
92
+ #
93
+ # @example Adding custom middleware
94
+ # class MyWorker
95
+ # include Shoryuken::Worker
96
+ #
97
+ # server_middleware do |chain|
98
+ # chain.add MyCustomMiddleware
99
+ # chain.remove Shoryuken::Middleware::Server::ActiveRecord
100
+ # end
101
+ # end
21
102
  def server_middleware
22
103
  @_server_chain ||= Shoryuken.server_middleware.dup
23
104
  yield @_server_chain if block_given?
24
105
  @_server_chain
25
106
  end
26
107
 
108
+ # Configures worker options including queue assignment, processing behavior,
109
+ # and SQS-specific settings. This is the main configuration method for workers.
110
+ #
111
+ # @param opts [Hash] Configuration options for the worker
112
+ # @option opts [String, Array<String>] :queue Queue name(s) this worker processes
113
+ # @option opts [Boolean] :batch (false) Process messages in batches of up to 10
114
+ # @option opts [Boolean] :auto_delete (false) Automatically delete messages after processing
115
+ # @option opts [Boolean] :auto_visibility_timeout (false) Automatically extend message visibility
116
+ # @option opts [Array<Integer>] :retry_intervals Exponential backoff retry intervals in seconds
117
+ # @option opts [Hash] :sqs Additional SQS client options
118
+ #
119
+ # @example Basic worker configuration
120
+ # class MyWorker
121
+ # include Shoryuken::Worker
122
+ # shoryuken_options queue: 'my_queue'
123
+ #
124
+ # def perform(sqs_msg, body)
125
+ # # Process the message
126
+ # end
127
+ # end
128
+ #
129
+ # @example Worker with auto-delete and retries
130
+ # class ReliableWorker
131
+ # include Shoryuken::Worker
132
+ # shoryuken_options queue: 'important_queue',
133
+ # auto_delete: true,
134
+ # retry_intervals: [1, 5, 25, 125]
135
+ # end
136
+ #
137
+ # @example Batch processing worker
138
+ # class BatchWorker
139
+ # include Shoryuken::Worker
140
+ # shoryuken_options queue: 'batch_queue', batch: true
141
+ #
142
+ # def perform(sqs_msgs, bodies)
143
+ # # Process array of up to 10 messages
144
+ # bodies.each { |body| process_item(body) }
145
+ # end
146
+ # end
147
+ #
148
+ # @example Multiple queues with priorities
149
+ # class MultiQueueWorker
150
+ # include Shoryuken::Worker
151
+ # shoryuken_options queue: ['high_priority', 'low_priority']
152
+ # end
153
+ #
154
+ # @example Auto-extending visibility timeout for long-running jobs
155
+ # class LongRunningWorker
156
+ # include Shoryuken::Worker
157
+ # shoryuken_options queue: 'slow_queue',
158
+ # auto_visibility_timeout: true
159
+ #
160
+ # def perform(sqs_msg, body)
161
+ # # Long processing that might exceed visibility timeout
162
+ # complex_processing(body)
163
+ # end
164
+ # end
27
165
  def shoryuken_options(opts = {})
28
166
  self.shoryuken_options_hash = get_shoryuken_options.merge(stringify_keys(opts || {}))
29
167
  normalize_worker_queue!
30
168
  end
31
169
 
170
+ # Checks if automatic visibility timeout extension is enabled for this worker.
171
+ # When enabled, Shoryuken automatically extends the message visibility timeout
172
+ # during processing to prevent the message from becoming visible to other consumers.
173
+ #
174
+ # @return [Boolean] true if auto visibility timeout is enabled
175
+ #
176
+ # @see #shoryuken_options Documentation for enabling auto_visibility_timeout
32
177
  def auto_visibility_timeout?
33
178
  !!get_shoryuken_options['auto_visibility_timeout']
34
179
  end
35
180
 
181
+ # Checks if exponential backoff retry is configured for this worker.
182
+ # When retry intervals are specified, failed jobs will be retried with
183
+ # increasing delays between attempts.
184
+ #
185
+ # @return [Boolean] true if retry intervals are configured
186
+ #
187
+ # @example Configuring exponential backoff
188
+ # shoryuken_options retry_intervals: [1, 5, 25, 125, 625]
189
+ # # Will retry after 1s, 5s, 25s, 125s, then 625s before giving up
190
+ #
191
+ # @see #shoryuken_options Documentation for configuring retry_intervals
36
192
  def exponential_backoff?
37
193
  !!get_shoryuken_options['retry_intervals']
38
194
  end
39
195
 
196
+ # Checks if automatic message deletion is enabled for this worker.
197
+ # When enabled, successfully processed messages are automatically deleted
198
+ # from the SQS queue. When disabled, you must manually delete messages
199
+ # or they will become visible again after the visibility timeout.
200
+ #
201
+ # @return [Boolean] true if auto delete is enabled
202
+ #
203
+ # @example Manual message deletion when auto_delete is false
204
+ # def perform(sqs_msg, body)
205
+ # process_message(body)
206
+ # # Manually delete the message after successful processing
207
+ # sqs_msg.delete
208
+ # end
209
+ #
210
+ # @see #shoryuken_options Documentation for enabling auto_delete
40
211
  def auto_delete?
41
212
  !!(get_shoryuken_options['delete'] || get_shoryuken_options['auto_delete'])
42
213
  end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'active_job'
5
+ require 'shoryuken/extensions/active_job_adapter'
6
+ require 'shoryuken/extensions/active_job_extensions'
7
+
8
+ RSpec.describe 'ActiveJob Continuations Integration' do
9
+ # Skip all tests in this suite if ActiveJob::Continuable is not available (Rails < 8.0)
10
+ before(:all) do
11
+ skip 'ActiveJob::Continuable not available (Rails < 8.0)' unless defined?(ActiveJob::Continuable)
12
+ end
13
+
14
+ # Test job that uses ActiveJob Continuations
15
+ class ContinuableTestJob < ActiveJob::Base
16
+ include ActiveJob::Continuable if defined?(ActiveJob::Continuable)
17
+
18
+ queue_as :default
19
+
20
+ class_attribute :executions_log, default: []
21
+ class_attribute :checkpoints_reached, default: []
22
+
23
+ def perform(max_iterations: 10)
24
+ self.class.executions_log << { execution: executions, started_at: Time.current }
25
+
26
+ step :initialize_work do
27
+ self.class.checkpoints_reached << "initialize_work_#{executions}"
28
+ end
29
+
30
+ step :process_items, start: cursor || 0 do
31
+ (cursor..max_iterations).each do |i|
32
+ self.class.checkpoints_reached << "processing_item_#{i}"
33
+
34
+ # Check if we should stop (checkpoint)
35
+ checkpoint
36
+
37
+ # Simulate some work
38
+ sleep 0.01
39
+
40
+ # Advance cursor
41
+ cursor.advance!
42
+ end
43
+ end
44
+
45
+ step :finalize_work do
46
+ self.class.checkpoints_reached << 'finalize_work'
47
+ end
48
+
49
+ self.class.executions_log.last[:completed] = true
50
+ end
51
+ end
52
+
53
+ describe 'stopping? method (unit tests)' do
54
+ it 'returns false when launcher is not initialized' do
55
+ adapter = ActiveJob::QueueAdapters::ShoryukenAdapter.new
56
+ expect(adapter.stopping?).to be false
57
+ end
58
+
59
+ it 'returns true when launcher is stopping' do
60
+ launcher = Shoryuken::Launcher.new
61
+ runner = Shoryuken::Runner.instance
62
+ runner.instance_variable_set(:@launcher, launcher)
63
+
64
+ adapter = ActiveJob::QueueAdapters::ShoryukenAdapter.new
65
+ expect(adapter.stopping?).to be false
66
+
67
+ launcher.instance_variable_set(:@stopping, true)
68
+ expect(adapter.stopping?).to be true
69
+ end
70
+ end
71
+
72
+ describe 'timestamp handling for continuation retries' do
73
+ it 'handles past timestamps for continuation retries' do
74
+ adapter = ActiveJob::QueueAdapters::ShoryukenAdapter.new
75
+ job = ContinuableTestJob.new
76
+ job.sqs_send_message_parameters = {}
77
+
78
+ # Mock the queue
79
+ queue = instance_double(Shoryuken::Queue, fifo?: false)
80
+ allow(Shoryuken::Client).to receive(:queues).and_return(queue)
81
+ allow(Shoryuken).to receive(:register_worker)
82
+ allow(queue).to receive(:send_message) do |params|
83
+ # Verify past timestamp results in immediate delivery (delay_seconds <= 0)
84
+ expect(params[:delay_seconds]).to be <= 0
85
+ end
86
+
87
+ # Enqueue with past timestamp (simulating continuation retry)
88
+ past_timestamp = Time.current.to_f - 60
89
+ adapter.enqueue_at(job, past_timestamp)
90
+ end
91
+ end
92
+
93
+ describe 'enqueue_at with continuation timestamps (unit tests)' do
94
+ let(:adapter) { ActiveJob::QueueAdapters::ShoryukenAdapter.new }
95
+ let(:job) do
96
+ job = ContinuableTestJob.new
97
+ job.sqs_send_message_parameters = {}
98
+ job
99
+ end
100
+ let(:queue) { instance_double(Shoryuken::Queue, fifo?: false) }
101
+
102
+ before do
103
+ allow(Shoryuken::Client).to receive(:queues).and_return(queue)
104
+ allow(Shoryuken).to receive(:register_worker)
105
+ @sent_messages = []
106
+ allow(queue).to receive(:send_message) do |params|
107
+ @sent_messages << params
108
+ end
109
+ end
110
+
111
+ it 'accepts past timestamps without error' do
112
+ past_timestamp = Time.current.to_f - 30
113
+
114
+ expect {
115
+ adapter.enqueue_at(job, past_timestamp)
116
+ }.not_to raise_error
117
+
118
+ expect(@sent_messages.size).to eq(1)
119
+ expect(@sent_messages.first[:delay_seconds]).to be <= 0
120
+ end
121
+
122
+ it 'accepts current timestamp' do
123
+ current_timestamp = Time.current.to_f
124
+
125
+ expect {
126
+ adapter.enqueue_at(job, current_timestamp)
127
+ }.not_to raise_error
128
+
129
+ expect(@sent_messages.size).to eq(1)
130
+ expect(@sent_messages.first[:delay_seconds]).to be_between(-1, 1)
131
+ end
132
+
133
+ it 'accepts future timestamp' do
134
+ future_timestamp = Time.current.to_f + 30
135
+
136
+ expect {
137
+ adapter.enqueue_at(job, future_timestamp)
138
+ }.not_to raise_error
139
+
140
+ expect(@sent_messages.size).to eq(1)
141
+ expect(@sent_messages.first[:delay_seconds]).to be > 0
142
+ expect(@sent_messages.first[:delay_seconds]).to be <= 30
143
+ end
144
+ end
145
+ end