sqewer 5.0.0 → 5.0.1

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.
@@ -7,19 +7,19 @@ module Sqewer
7
7
  # Unserialize the job
8
8
  def around_deserialization(serializer, msg_id, msg_payload)
9
9
  return yield unless (defined?(Appsignal) && Appsignal.active?)
10
-
10
+
11
11
  Appsignal.monitor_transaction('perform_job.demarshal',
12
12
  :class => serializer.class.to_s, :params => {:recepit_handle => msg_id}, :method => 'deserialize') do
13
13
  yield
14
14
  end
15
15
  end
16
-
16
+
17
17
  # Run the job with Appsignal monitoring.
18
18
  def around_execution(job, context)
19
19
  return yield unless (defined?(Appsignal) && Appsignal.active?)
20
-
20
+ job_params = job.respond_to?(:to_h) ? job.to_h : {}
21
21
  Appsignal.monitor_transaction('perform_job.sqewer',
22
- :class => job.class.to_s, :params => job.to_h, :method => 'run') do |t|
22
+ :class => job.class.to_s, :params => job_params, :method => 'run') do |t|
23
23
  context['appsignal.transaction'] = t
24
24
  yield
25
25
  end
@@ -0,0 +1,12 @@
1
+ module Sqewer
2
+ require 'sqewer/extensions/active_job_adapter' if defined?(::ActiveJob)
3
+
4
+ # Loads the Sqewer components that provide ActiveJob compatibility
5
+ class Railtie < Rails::Railtie
6
+ initializer "sqewer.load_active_job_adapter" do |app|
7
+ if defined?(::ActiveJob)
8
+ Rails.logger.warn "sqewer set as ActiveJob adapter. Make sure to call 'Rails.application.eager_load!` in your worker process"
9
+ end
10
+ end
11
+ end
12
+ end
@@ -1,18 +1,18 @@
1
1
  # Allows arbitrary wrapping of the job deserialization and job execution procedures
2
2
  class Sqewer::MiddlewareStack
3
-
3
+
4
4
  # Returns the default middleware stack, which is empty (an instance of None).
5
5
  #
6
6
  # @return [MiddlewareStack] the default empty stack
7
7
  def self.default
8
8
  @instance ||= new
9
9
  end
10
-
10
+
11
11
  # Creates a new MiddlewareStack. Once created, handlers can be added using `:<<`
12
12
  def initialize
13
13
  @handlers = []
14
14
  end
15
-
15
+
16
16
  # Adds a handler. The handler should respond to :around_deserialization and #around_execution.
17
17
  #
18
18
  # @param handler[#around_deserializarion, #around_execution] The middleware item to insert
@@ -21,13 +21,13 @@ class Sqewer::MiddlewareStack
21
21
  @handlers << handler
22
22
  # TODO: cache the wrapping proc
23
23
  end
24
-
24
+
25
25
  def around_execution(job, context, &inner_block)
26
26
  return yield if @handlers.empty?
27
-
27
+
28
28
  responders = @handlers.select{|e| e.respond_to?(:around_execution) }
29
29
  responders.reverse.inject(inner_block) {|outer_block, middleware_object|
30
- ->{
30
+ ->{
31
31
  middleware_object.public_send(:around_execution, job, context, &outer_block)
32
32
  }
33
33
  }.call
@@ -35,7 +35,7 @@ class Sqewer::MiddlewareStack
35
35
 
36
36
  def around_deserialization(serializer, message_id, message_body, &inner_block)
37
37
  return yield if @handlers.empty?
38
-
38
+
39
39
  responders = @handlers.select{|e| e.respond_to?(:around_deserialization) }
40
40
  responders.reverse.inject(inner_block) {|outer_block, middleware_object|
41
41
  ->{ middleware_object.public_send(:around_deserialization, serializer, message_id, message_body, &outer_block) }
@@ -4,6 +4,6 @@ module Sqewer::NullLogger
4
4
  (Logger.instance_methods- Object.instance_methods).each do | null_method |
5
5
  define_method(null_method){|*a| }
6
6
  end
7
-
7
+
8
8
  extend self
9
9
  end
@@ -0,0 +1,17 @@
1
+ module Sqewer
2
+ class Resubmit
3
+ attr_reader :job
4
+ attr_reader :execute_after
5
+
6
+ def initialize(job_to_resubmit, execute_after_timestamp)
7
+ @job = job_to_resubmit
8
+ @execute_after = execute_after_timestamp
9
+ end
10
+
11
+ def run(ctx)
12
+ # Take the maximum delay period SQS allows
13
+ required_delay = (@execute_after - Time.now.to_i)
14
+ ctx.submit!(@job, delay_seconds: required_delay)
15
+ end
16
+ end
17
+ end
@@ -4,7 +4,7 @@
4
4
  # custom job objects from S3 bucket notifications, you might want to override this
5
5
  # class and feed the overridden instance to {Sqewer::Worker}.
6
6
  class Sqewer::Serializer
7
-
7
+
8
8
  # Returns the default Serializer, of which we store one instance
9
9
  # (because the serializer is stateless).
10
10
  #
@@ -12,10 +12,9 @@ class Sqewer::Serializer
12
12
  def self.default
13
13
  @instance ||= new
14
14
  end
15
-
15
+
16
16
  AnonymousJobClass = Class.new(StandardError)
17
- ArityMismatch = Class.new(ArgumentError)
18
-
17
+
19
18
  # Instantiate a Job object from a message body string. If the
20
19
  # returned result is `nil`, the job will be skipped.
21
20
  #
@@ -24,48 +23,49 @@ class Sqewer::Serializer
24
23
  def unserialize(message_body)
25
24
  job_ticket_hash = JSON.parse(message_body, symbolize_names: true)
26
25
  raise "Job ticket must unmarshal into a Hash" unless job_ticket_hash.is_a?(Hash)
27
-
28
- job_ticket_hash = convert_old_ticket_format(job_ticket_hash) if job_ticket_hash[:job_class]
29
-
26
+
30
27
  # Use fetch() to raise a descriptive KeyError if none
31
28
  job_class_name = job_ticket_hash.delete(:_job_class)
32
29
  raise ":_job_class not set in the ticket" unless job_class_name
33
30
  job_class = Kernel.const_get(job_class_name)
31
+
32
+ # Grab the parameter that is responsible for executing the job later. If it is not set,
33
+ # use a default that will put us ahead of that execution deadline from the start.
34
+ t = Time.now.to_i
35
+ execute_after = job_ticket_hash.fetch(:_execute_after) { t - 5 }
34
36
 
35
37
  job_params = job_ticket_hash.delete(:_job_params)
36
- if job_params.nil? || job_params.empty?
38
+ job = if job_params.nil? || job_params.empty?
37
39
  job_class.new # no args
38
40
  else
39
- begin
40
- job_class.new(**job_params) # The rest of the message are keyword arguments for the job
41
- rescue ArgumentError => e
42
- raise ArityMismatch, "Could not instantiate #{job_class} because it did not accept the arguments #{job_params.inspect}"
43
- end
41
+ job_class.new(**job_params) # The rest of the message are keyword arguments for the job
44
42
  end
43
+
44
+ # If the job is not up for execution now, wrap it with something that will
45
+ # re-submit it for later execution when the run() method is called
46
+ return ::Sqewer::Resubmit.new(job, execute_after) if execute_after > t
47
+
48
+ job
45
49
  end
46
-
50
+
47
51
  # Converts the given Job into a string, which can be submitted to the queue
48
52
  #
49
53
  # @param job[#to_h] an object that supports `to_h`
54
+ # @param execute_after_timestamp[#to_i, nil] the Unix timestamp after which the job may be executed
50
55
  # @return [String] serialized string ready to be put into the queue
51
- def serialize(job)
56
+ def serialize(job, execute_after_timestamp = nil)
52
57
  job_class_name = job.class.to_s
53
-
58
+
54
59
  begin
55
60
  Kernel.const_get(job_class_name)
56
61
  rescue NameError
57
62
  raise AnonymousJobClass, "The class of #{job.inspect} could not be resolved and will not restore to a Job"
58
63
  end
59
-
64
+
60
65
  job_params = job.respond_to?(:to_h) ? job.to_h : nil
61
66
  job_ticket_hash = {_job_class: job_class_name, _job_params: job_params}
67
+ job_ticket_hash[:_execute_after] = execute_after_timestamp.to_i if execute_after_timestamp
68
+
62
69
  JSON.dump(job_ticket_hash)
63
70
  end
64
-
65
- private
66
-
67
- def convert_old_ticket_format(hash_of_properties)
68
- job_class = hash_of_properties.delete(:job_class)
69
- {_job_class: job_class, _job_params: hash_of_properties}
70
- end
71
- end
71
+ end
@@ -7,19 +7,19 @@
7
7
  module Sqewer::SimpleJob
8
8
  UnknownJobAttribute = Class.new(StandardError)
9
9
  MissingAttribute = Class.new(StandardError)
10
-
10
+
11
11
  EQ_END = /(\w+)(\=)$/
12
-
12
+
13
13
  # Returns the list of methods on the object that have corresponding accessors.
14
14
  # This is then used by #inspect to compose a list of the job parameters, formatted
15
15
  # as an inspected Hash.
16
16
  #
17
- # @return [Array<Symbol>] the array of attributes to show via inspect
17
+ # @return [Array<Symbol>] the array of attributes to show via inspect
18
18
  def inspectable_attributes
19
19
  # All the attributes that have accessors
20
20
  methods.grep(EQ_END).map{|e| e.to_s.gsub(EQ_END, '\1')}.map(&:to_sym)
21
21
  end
22
-
22
+
23
23
  # Returns the inspection string with the job and all of it's instantiation keyword attributes.
24
24
  # If `inspectable_attributes` has been overridden, the attributes returned by that method will be the
25
25
  # ones returned in the inspection string.
@@ -36,7 +36,7 @@ module Sqewer::SimpleJob
36
36
  end
37
37
  "<#{self.class}:#{h.inspect}>"
38
38
  end
39
-
39
+
40
40
  # Initializes a new Job with the given job args. Will check for presence of
41
41
  # accessor methods for each of the arguments, and call them with the arguments given.
42
42
  #
@@ -49,30 +49,30 @@ module Sqewer::SimpleJob
49
49
  @simple_job_args = jobargs.keys
50
50
  touched_attributes = Set.new
51
51
  jobargs.each do |(k,v)|
52
-
52
+
53
53
  accessor = "#{k}="
54
54
  touched_attributes << k
55
55
  unless respond_to?(accessor)
56
56
  raise UnknownJobAttribute, "Unknown attribute #{k.inspect} for #{self.class}"
57
57
  end
58
-
58
+
59
59
  send("#{k}=", v)
60
60
  end
61
-
61
+
62
62
  accessors = methods.grep(EQ_END).map{|method_name| method_name.to_s.gsub(EQ_END, '\1').to_sym }
63
63
  settable_attributes = Set.new(accessors)
64
64
  missing_attributes = settable_attributes - touched_attributes
65
-
65
+
66
66
  missing_attributes.each do | attr |
67
67
  raise MissingAttribute, "Missing job attribute #{attr.inspect}"
68
68
  end
69
69
  end
70
-
70
+
71
71
  def to_h
72
72
  keys_and_values = @simple_job_args.each_with_object({}) do |k, h|
73
73
  h[k] = send(k)
74
74
  end
75
-
75
+
76
76
  keys_and_values
77
77
  end
78
78
  end
@@ -13,11 +13,11 @@ class Sqewer::StateLock < SimpleDelegator
13
13
  m.permit_transition :starting => :failed # Failed to start
14
14
  __setobj__(m)
15
15
  end
16
-
16
+
17
17
  def in_state?(some_state)
18
18
  @m.synchronize { __getobj__.in_state?(some_state) }
19
19
  end
20
-
20
+
21
21
  def transition!(to_state)
22
22
  @m.synchronize { __getobj__.transition!(to_state) }
23
23
  end
@@ -3,14 +3,28 @@
3
3
  # and the serializer (something that responds to `#serialize`) to
4
4
  # convert the job into the string that will be put in the queue.
5
5
  class Sqewer::Submitter < Struct.new(:connection, :serializer)
6
-
6
+
7
7
  # Returns a default Submitter, configured with the default connection
8
8
  # and the default serializer.
9
9
  def self.default
10
10
  new(Sqewer::Connection.default, Sqewer::Serializer.default)
11
11
  end
12
-
12
+
13
13
  def submit!(job, **kwargs_for_send)
14
- connection.send_message(serializer.serialize(job), **kwargs_for_send)
14
+ message_body = if delay_by_seconds = kwargs_for_send[:delay_seconds]
15
+ clamped_delay = clamp_delay(delay_by_seconds)
16
+ kwargs_for_send[:delay_seconds] = clamped_delay
17
+ # Pass the actual delay value to the serializer, to be stored in executed_at
18
+ serializer.serialize(job, Time.now.to_i + delay_by_seconds)
19
+ else
20
+ serializer.serialize(job)
21
+ end
22
+ connection.send_message(message_body, **kwargs_for_send)
23
+ end
24
+
25
+ private
26
+
27
+ def clamp_delay(delay)
28
+ [1, 899, delay].sort[1]
15
29
  end
16
30
  end
@@ -1,3 +1,3 @@
1
1
  module Sqewer
2
- VERSION = '5.0.0'
2
+ VERSION = '5.0.1'
3
3
  end
data/lib/sqewer/worker.rb CHANGED
@@ -8,38 +8,38 @@ class Sqewer::Worker
8
8
  DEFAULT_NUM_THREADS = 4
9
9
  SLEEP_SECONDS_ON_EMPTY_QUEUE = 1
10
10
  THROTTLE_FACTOR = 2
11
-
11
+
12
12
  # @return [Logger] The logger used for job execution
13
13
  attr_reader :logger
14
-
14
+
15
15
  # @return [Sqewer::Connection] The connection for sending and receiving messages
16
16
  attr_reader :connection
17
-
17
+
18
18
  # @return [Sqewer::Serializer] The serializer for unmarshalling and marshalling
19
19
  attr_reader :serializer
20
-
20
+
21
21
  # @return [Sqewer::MiddlewareStack] The stack used when executing the job
22
22
  attr_reader :middleware_stack
23
-
23
+
24
24
  # @return [Class] The class to use when instantiating the execution context
25
25
  attr_reader :execution_context_class
26
-
26
+
27
27
  # @return [Class] The class used to create the Submitter used by jobs to spawn other jobs
28
28
  attr_reader :submitter_class
29
-
29
+
30
30
  # @return [Array<Thread>] all the currently running threads of the Worker
31
31
  attr_reader :threads
32
-
32
+
33
33
  # @return [Fixnum] the number of worker threads set up for this Worker
34
34
  attr_reader :num_threads
35
-
36
- # Returns the default Worker instance, configured based on the default components
35
+
36
+ # Returns a Worker instance, configured based on the default components
37
37
  #
38
38
  # @return [Sqewer::Worker]
39
39
  def self.default
40
- @default ||= new
40
+ new
41
41
  end
42
-
42
+
43
43
  # Creates a new Worker. The Worker, unlike it is in the Rails tradition, is only responsible for
44
44
  # the actual processing of jobs, and not for the job arguments.
45
45
  #
@@ -58,7 +58,7 @@ class Sqewer::Worker
58
58
  middleware_stack: Sqewer::MiddlewareStack.default,
59
59
  logger: Logger.new($stderr),
60
60
  num_threads: DEFAULT_NUM_THREADS)
61
-
61
+
62
62
  @logger = logger
63
63
  @connection = connection
64
64
  @serializer = serializer
@@ -66,38 +66,38 @@ class Sqewer::Worker
66
66
  @execution_context_class = execution_context_class
67
67
  @submitter_class = submitter_class
68
68
  @num_threads = num_threads
69
-
69
+
70
70
  @threads = []
71
-
71
+
72
72
  raise ArgumentError, "num_threads must be > 0" unless num_threads > 0
73
-
73
+
74
74
  @execution_counter = Sqewer::AtomicCounter.new
75
-
75
+
76
76
  @state = Sqewer::StateLock.new
77
77
  end
78
-
78
+
79
79
  # Start listening on the queue, spin up a number of consumer threads that will execute the jobs.
80
80
  #
81
81
  # @param num_threads[Fixnum] the number of consumer/executor threads to spin up
82
82
  # @return [void]
83
83
  def start
84
84
  @state.transition! :starting
85
-
85
+
86
86
  @logger.info { '[worker] Starting with %d consumer threads' % @num_threads }
87
87
  @execution_queue = Queue.new
88
-
88
+
89
89
  consumers = (1..@num_threads).map do
90
90
  Thread.new do
91
91
  catch(:goodbye) { loop {take_and_execute} }
92
92
  end
93
93
  end
94
-
94
+
95
95
  # Create the provider thread. When the execution queue is exhausted,
96
96
  # grab new messages and place them on the local queue.
97
97
  provider = Thread.new do
98
98
  loop do
99
99
  break if stopping?
100
-
100
+
101
101
  if queue_has_capacity?
102
102
  messages = @connection.receive_messages
103
103
  if messages.any?
@@ -110,12 +110,12 @@ class Sqewer::Worker
110
110
  else
111
111
  @logger.debug { "[worker] Cache is full (%d items), postponing receive" % @execution_queue.length }
112
112
  sleep SLEEP_SECONDS_ON_EMPTY_QUEUE
113
- end
113
+ end
114
114
  end
115
115
  end
116
-
116
+
117
117
  @threads = consumers + [provider]
118
-
118
+
119
119
  # If any of our threads are already dead, it means there is some misconfiguration and startup failed
120
120
  if @threads.any?{|t| !t.alive? }
121
121
  @threads.map(&:kill)
@@ -126,7 +126,7 @@ class Sqewer::Worker
126
126
  @logger.info { '[worker] Started, %d consumer threads' % consumers.length }
127
127
  end
128
128
  end
129
-
129
+
130
130
  # Attempts to softly stop the running consumers and the producer. Once the call is made,
131
131
  # all the threads will stop after the local cache of messages is emptied. This is to ensure that
132
132
  # message drops do not happen just because the worker is about to be terminated.
@@ -140,20 +140,20 @@ class Sqewer::Worker
140
140
  loop do
141
141
  n_live = @threads.select(&:alive?).length
142
142
  break if n_live.zero?
143
-
143
+
144
144
  n_dead = @threads.length - n_live
145
145
  @logger.info { '[worker] Staged shutdown, %d threads alive, %d have quit, %d jobs in local cache' %
146
146
  [n_live, n_dead, @execution_queue.length] }
147
-
147
+
148
148
  sleep 2
149
149
  end
150
-
150
+
151
151
  @threads.map(&:join)
152
152
  @logger.info { '[worker] Stopped'}
153
153
  @state.transition! :stopped
154
154
  true
155
155
  end
156
-
156
+
157
157
  # Peforms a hard shutdown by killing all the threads
158
158
  def kill
159
159
  @state.transition! :stopping
@@ -162,7 +162,7 @@ class Sqewer::Worker
162
162
  @logger.info { '[worker] Stopped'}
163
163
  @state.transition! :stopped
164
164
  end
165
-
165
+
166
166
  # Prints the status and the backtraces of all controlled threads to the logger
167
167
  def debug_thread_information!
168
168
  @threads.each do | t |
@@ -170,48 +170,48 @@ class Sqewer::Worker
170
170
  @logger.debug { t.backtrace }
171
171
  end
172
172
  end
173
-
173
+
174
174
  private
175
-
175
+
176
176
  def stopping?
177
177
  @state.in_state?(:stopping)
178
178
  end
179
-
179
+
180
180
  def queue_has_capacity?
181
181
  @execution_queue.length < (@num_threads * THROTTLE_FACTOR)
182
182
  end
183
-
183
+
184
184
  def handle_message(message)
185
185
  return unless message.receipt_handle
186
-
186
+
187
187
  # Create a messagebox that buffers all the calls to Connection, so that
188
188
  # we can send out those commands in one go (without interfering with senders
189
189
  # on other threads, as it seems the Aws::SQS::Client is not entirely
190
190
  # thread-safe - or at least not it's HTTP client part).
191
191
  box = Sqewer::ConnectionMessagebox.new(connection)
192
192
  return box.delete_message(message.receipt_handle) unless message.has_body?
193
-
193
+
194
194
  job = middleware_stack.around_deserialization(serializer, message.receipt_handle, message.body) do
195
195
  serializer.unserialize(message.body)
196
196
  end
197
197
  return unless job
198
-
198
+
199
199
  submitter = submitter_class.new(box, serializer)
200
200
  context = execution_context_class.new(submitter, {'logger' => logger})
201
-
201
+
202
202
  t = Time.now
203
203
  middleware_stack.around_execution(job, context) do
204
204
  job.method(:run).arity.zero? ? job.run : job.run(context)
205
205
  end
206
206
  box.delete_message(message.receipt_handle)
207
-
207
+
208
208
  delta = Time.now - t
209
209
  logger.info { "[worker] Finished %s in %0.2fs" % [job.inspect, delta] }
210
210
  ensure
211
211
  n_flushed = box.flush!
212
212
  logger.debug { "[worker] Flushed %d connection commands" % n_flushed } if n_flushed.nonzero?
213
213
  end
214
-
214
+
215
215
  def take_and_execute
216
216
  message = @execution_queue.pop(nonblock=true)
217
217
  handle_message(message)
@@ -222,31 +222,31 @@ class Sqewer::Worker
222
222
  @logger.error { '[worker] Failed "%s..." with %s: %s' % [message.inspect[0..32], e.class, e.message] }
223
223
  e.backtrace.each { |s| @logger.error{"\t#{s}"} }
224
224
  end
225
-
225
+
226
226
  def perform(message)
227
227
  # Create a messagebox that buffers all the calls to Connection, so that
228
228
  # we can send out those commands in one go (without interfering with senders
229
229
  # on other threads, as it seems the Aws::SQS::Client is not entirely
230
230
  # thread-safe - or at least not it's HTTP client part).
231
231
  box = Sqewer::ConnectionMessagebox.new(connection)
232
-
232
+
233
233
  job = middleware_stack.around_deserialization(serializer, message.receipt_handle, message.body) do
234
234
  serializer.unserialize(message.body)
235
235
  end
236
236
  return unless job
237
-
237
+
238
238
  submitter = submitter_class.new(box, serializer)
239
239
  context = execution_context_class.new(submitter, {'logger' => logger})
240
-
240
+
241
241
  t = Time.now
242
242
  middleware_stack.around_execution(job, context) do
243
243
  job.method(:run).arity.zero? ? job.run : job.run(context)
244
244
  end
245
-
245
+
246
246
  # Perform two flushes, one for any possible jobs the job has spawned,
247
247
  # and one for the job delete afterwards
248
248
  box.delete_message(message.receipt_handle)
249
-
249
+
250
250
  delta = Time.now - t
251
251
  logger.info { "[worker] Finished %s in %0.2fs" % [job.inspect, delta] }
252
252
  ensure