sneakers_custom_bunny 1.0.4

Sign up to get free protection for your applications and to get access to all the features.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +9 -0
  3. data/.travis.yml +6 -0
  4. data/CHANGELOG.md +20 -0
  5. data/Gemfile +3 -0
  6. data/Guardfile +8 -0
  7. data/LICENSE.txt +22 -0
  8. data/README.md +172 -0
  9. data/ROADMAP.md +18 -0
  10. data/Rakefile +11 -0
  11. data/bin/sneakers +5 -0
  12. data/examples/benchmark_worker.rb +20 -0
  13. data/examples/max_retry_handler.rb +78 -0
  14. data/examples/metrics_worker.rb +28 -0
  15. data/examples/newrelic_metrics_worker.rb +40 -0
  16. data/examples/profiling_worker.rb +69 -0
  17. data/examples/sneakers.conf.rb.example +11 -0
  18. data/examples/title_scraper.rb +23 -0
  19. data/examples/workflow_worker.rb +23 -0
  20. data/lib/sneakers.rb +83 -0
  21. data/lib/sneakers/cli.rb +115 -0
  22. data/lib/sneakers/concerns/logging.rb +34 -0
  23. data/lib/sneakers/concerns/metrics.rb +34 -0
  24. data/lib/sneakers/configuration.rb +59 -0
  25. data/lib/sneakers/handlers/maxretry.rb +191 -0
  26. data/lib/sneakers/handlers/oneshot.rb +30 -0
  27. data/lib/sneakers/metrics/logging_metrics.rb +16 -0
  28. data/lib/sneakers/metrics/newrelic_metrics.rb +37 -0
  29. data/lib/sneakers/metrics/null_metrics.rb +13 -0
  30. data/lib/sneakers/metrics/statsd_metrics.rb +21 -0
  31. data/lib/sneakers/publisher.rb +34 -0
  32. data/lib/sneakers/queue.rb +65 -0
  33. data/lib/sneakers/runner.rb +82 -0
  34. data/lib/sneakers/spawner.rb +27 -0
  35. data/lib/sneakers/support/production_formatter.rb +11 -0
  36. data/lib/sneakers/support/utils.rb +18 -0
  37. data/lib/sneakers/tasks.rb +34 -0
  38. data/lib/sneakers/version.rb +3 -0
  39. data/lib/sneakers/worker.rb +151 -0
  40. data/lib/sneakers/workergroup.rb +47 -0
  41. data/sneakers.gemspec +35 -0
  42. data/spec/fixtures/require_worker.rb +17 -0
  43. data/spec/sneakers/cli_spec.rb +63 -0
  44. data/spec/sneakers/concerns/logging_spec.rb +39 -0
  45. data/spec/sneakers/concerns/metrics_spec.rb +38 -0
  46. data/spec/sneakers/configuration_spec.rb +75 -0
  47. data/spec/sneakers/publisher_spec.rb +83 -0
  48. data/spec/sneakers/queue_spec.rb +115 -0
  49. data/spec/sneakers/runner_spec.rb +26 -0
  50. data/spec/sneakers/sneakers_spec.rb +75 -0
  51. data/spec/sneakers/support/utils_spec.rb +44 -0
  52. data/spec/sneakers/worker_handlers_spec.rb +390 -0
  53. data/spec/sneakers/worker_spec.rb +463 -0
  54. data/spec/spec_helper.rb +13 -0
  55. metadata +306 -0
@@ -0,0 +1,191 @@
1
+ require 'base64'
2
+ require 'json'
3
+
4
+ module Sneakers
5
+ module Handlers
6
+ #
7
+ # Maxretry uses dead letter policies on Rabbitmq to requeue and retry
8
+ # messages after failure (rejections, errors and timeouts). When the maximum
9
+ # number of retries is reached it will put the message on an error queue.
10
+ # This handler will only retry at the queue level. To accomplish that, the
11
+ # setup is a bit complex.
12
+ #
13
+ # Input:
14
+ # worker_exchange (eXchange)
15
+ # worker_queue (Queue)
16
+ # We create:
17
+ # worker_queue-retry - (X) where we setup the worker queue to dead-letter.
18
+ # worker_queue-retry - (Q) queue bound to ^ exchange, dead-letters to
19
+ # worker_queue-retry-requeue.
20
+ # worker_queue-error - (X) where to send max-retry failures
21
+ # worker_queue-error - (Q) bound to worker_queue-error.
22
+ # worker_queue-retry-requeue - (X) exchange to bind worker_queue to for
23
+ # requeuing directly to the worker_queue.
24
+ #
25
+ # This requires that you setup arguments to the worker queue to line up the
26
+ # dead letter queue. See the example for more information.
27
+ #
28
+ # Many of these can be override with options:
29
+ # - retry_exchange - sets retry exchange & queue
30
+ # - retry_error_exchange - sets error exchange and queue
31
+ # - retry_requeue_exchange - sets the exchange created to re-queue things
32
+ # back to the worker queue.
33
+ #
34
+ class Maxretry
35
+
36
+ def initialize(channel, queue, opts)
37
+ @worker_queue_name = queue.name
38
+ Sneakers.logger.debug do
39
+ "#{log_prefix} creating handler, opts=#{opts}"
40
+ end
41
+
42
+ @channel = channel
43
+ @opts = opts
44
+
45
+ # Construct names, defaulting where suitable
46
+ retry_name = @opts[:retry_exchange] || "#{@worker_queue_name}-retry"
47
+ error_name = @opts[:retry_error_exchange] || "#{@worker_queue_name}-error"
48
+ requeue_name = @opts[:retry_requeue_exchange] || "#{@worker_queue_name}-retry-requeue"
49
+
50
+ # Create the exchanges
51
+ @retry_exchange, @error_exchange, @requeue_exchange = [retry_name, error_name, requeue_name].map do |name|
52
+ Sneakers.logger.debug { "#{log_prefix} creating exchange=#{name}" }
53
+ @channel.exchange(name,
54
+ :type => 'topic',
55
+ :durable => opts[:durable])
56
+ end
57
+
58
+ # Create the queues and bindings
59
+ Sneakers.logger.debug do
60
+ "#{log_prefix} creating queue=#{retry_name} x-dead-letter-exchange=#{requeue_name}"
61
+ end
62
+ @retry_queue = @channel.queue(retry_name,
63
+ :durable => opts[:durable],
64
+ :arguments => {
65
+ :'x-dead-letter-exchange' => requeue_name,
66
+ :'x-message-ttl' => @opts[:retry_timeout] || 60000
67
+ })
68
+ @retry_queue.bind(@retry_exchange, :routing_key => '#')
69
+
70
+ Sneakers.logger.debug do
71
+ "#{log_prefix} creating queue=#{error_name}"
72
+ end
73
+ @error_queue = @channel.queue(error_name,
74
+ :durable => opts[:durable])
75
+ @error_queue.bind(@error_exchange, :routing_key => '#')
76
+
77
+ # Finally, bind the worker queue to our requeue exchange
78
+ queue.bind(@requeue_exchange, :routing_key => '#')
79
+
80
+ @max_retries = @opts[:retry_max_times] || 5
81
+
82
+ end
83
+
84
+ def acknowledge(hdr, props, msg)
85
+ @channel.acknowledge(hdr.delivery_tag, false)
86
+ end
87
+
88
+ def reject(hdr, props, msg, requeue = false)
89
+ if requeue
90
+ # This was explicitly rejected specifying it be requeued so we do not
91
+ # want it to pass through our retry logic.
92
+ @channel.reject(hdr.delivery_tag, requeue)
93
+ else
94
+ handle_retry(hdr, props, msg, :reject)
95
+ end
96
+ end
97
+
98
+
99
+ def error(hdr, props, msg, err)
100
+ handle_retry(hdr, props, msg, err)
101
+ end
102
+
103
+ def timeout(hdr, props, msg)
104
+ handle_retry(hdr, props, msg, :timeout)
105
+ end
106
+
107
+ def noop(hdr, props, msg)
108
+
109
+ end
110
+
111
+ # Helper logic for retry handling. This will reject the message if there
112
+ # are remaining retries left on it, otherwise it will publish it to the
113
+ # error exchange along with the reason.
114
+ # @param hdr [Bunny::DeliveryInfo]
115
+ # @param props [Bunny::MessageProperties]
116
+ # @param msg [String] The message
117
+ # @param reason [String, Symbol, Exception] Reason for the retry, included
118
+ # in the JSON we put on the error exchange.
119
+ def handle_retry(hdr, props, msg, reason)
120
+ # +1 for the current attempt
121
+ num_attempts = failure_count(props[:headers]) + 1
122
+ if num_attempts <= @max_retries
123
+ # We call reject which will route the message to the
124
+ # x-dead-letter-exchange (ie. retry exchange) on the queue
125
+ Sneakers.logger.info do
126
+ "#{log_prefix} msg=retrying, count=#{num_attempts}, headers=#{props[:headers]}"
127
+ end
128
+ @channel.reject(hdr.delivery_tag, false)
129
+ # TODO: metrics
130
+ else
131
+ # Retried more than the max times
132
+ # Publish the original message with the routing_key to the error exchange
133
+ Sneakers.logger.info do
134
+ "#{log_prefix} msg=failing, retry_count=#{num_attempts}, reason=#{reason}"
135
+ end
136
+ data = {
137
+ error: reason,
138
+ num_attempts: num_attempts,
139
+ failed_at: Time.now.iso8601,
140
+ payload: Base64.encode64(msg.to_s)
141
+ }.tap do |hash|
142
+ if reason.is_a?(Exception)
143
+ hash[:error_class] = reason.class
144
+ hash[:error_message] = "#{reason}"
145
+ if reason.backtrace
146
+ hash[:backtrace] = reason.backtrace.take(10).join(', ')
147
+ end
148
+ end
149
+ end.to_json
150
+ @error_exchange.publish(data, :routing_key => hdr.routing_key)
151
+ @channel.acknowledge(hdr.delivery_tag, false)
152
+ # TODO: metrics
153
+ end
154
+ end
155
+ private :handle_retry
156
+
157
+ # Uses the x-death header to determine the number of failures this job has
158
+ # seen in the past. This does not count the current failure. So for
159
+ # instance, the first time the job fails, this will return 0, the second
160
+ # time, 1, etc.
161
+ # @param headers [Hash] Hash of headers that Rabbit delivers as part of
162
+ # the message
163
+ # @return [Integer] Count of number of failures.
164
+ def failure_count(headers)
165
+ if headers.nil? || headers['x-death'].nil?
166
+ 0
167
+ else
168
+ x_death_array = headers['x-death'].select do |x_death|
169
+ x_death['queue'] == @worker_queue_name
170
+ end
171
+ if x_death_array.count > 0 && x_death_array.first['count']
172
+ # Newer versions of RabbitMQ return headers with a count key
173
+ x_death_array.inject(0) {|sum, x_death| sum + x_death['count']}
174
+ else
175
+ # Older versions return a separate x-death header for each failure
176
+ x_death_array.count
177
+ end
178
+ end
179
+ end
180
+ private :failure_count
181
+
182
+ # Prefix all of our log messages so they are easier to find. We don't have
183
+ # the worker, so the next best thing is the queue name.
184
+ def log_prefix
185
+ "Maxretry handler [queue=#{@worker_queue_name}]"
186
+ end
187
+ private :log_prefix
188
+
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,30 @@
1
+ module Sneakers
2
+ module Handlers
3
+ class Oneshot
4
+ def initialize(channel, queue, opts)
5
+ @channel = channel
6
+ @opts = opts
7
+ end
8
+
9
+ def acknowledge(hdr, props, msg)
10
+ @channel.acknowledge(hdr.delivery_tag, false)
11
+ end
12
+
13
+ def reject(hdr, props, msg, requeue=false)
14
+ @channel.reject(hdr.delivery_tag, requeue)
15
+ end
16
+
17
+ def error(hdr, props, msg, err)
18
+ reject(hdr, props, msg)
19
+ end
20
+
21
+ def timeout(hdr, props, msg)
22
+ reject(hdr, props, msg)
23
+ end
24
+
25
+ def noop(hdr, props, msg)
26
+
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,16 @@
1
+ module Sneakers
2
+ module Metrics
3
+ class LoggingMetrics
4
+ def increment(metric)
5
+ Sneakers.logger.info("INC: #{metric}")
6
+ end
7
+
8
+ def timing(metric, &block)
9
+ start = Time.now
10
+ block.call
11
+ Sneakers.logger.info("TIME: #{metric} #{Time.now - start}")
12
+ end
13
+ end
14
+ end
15
+ end
16
+
@@ -0,0 +1,37 @@
1
+ module Sneakers
2
+ module Metrics
3
+ class NewrelicMetrics
4
+
5
+ def self.eagent(eagent = nil)
6
+ @eagent = eagent || @eagent
7
+ end
8
+
9
+ def initialize()
10
+ #@connection = conn
11
+ end
12
+
13
+ def increment(metric)
14
+ record_stat metric, 1
15
+ end
16
+
17
+ def record_stat(metric, num)
18
+ stats(metric).record_data_point(num)
19
+ rescue Exception => e
20
+ puts "NewrelicMetrics#record_stat: #{e}"
21
+ end
22
+
23
+ def timing(metric, &block)
24
+ start = Time.now
25
+ block.call
26
+ record_stat(metric, ((Time.now - start)*1000).floor)
27
+ end
28
+
29
+ def stats(metric)
30
+ metric.gsub! "\.", "\/"
31
+ NewrelicMetrics.eagent::Agent.get_stats("Custom/#{metric}")
32
+ end
33
+
34
+ end
35
+ end
36
+ end
37
+
@@ -0,0 +1,13 @@
1
+ module Sneakers
2
+ module Metrics
3
+ class NullMetrics
4
+ def increment(metric)
5
+ end
6
+
7
+ def timing(metric, &block)
8
+ block.call
9
+ end
10
+ end
11
+ end
12
+ end
13
+
@@ -0,0 +1,21 @@
1
+ module Sneakers
2
+ module Metrics
3
+ class StatsdMetrics
4
+ def initialize(conn)
5
+ @connection = conn
6
+ end
7
+
8
+ def increment(metric)
9
+ @connection.increment(metric)
10
+ end
11
+
12
+ def timing(metric, &block)
13
+ start = Time.now
14
+ block.call
15
+ @connection.timing(metric, ((Time.now - start)*1000).floor)
16
+ end
17
+
18
+ end
19
+ end
20
+ end
21
+
@@ -0,0 +1,34 @@
1
+ module Sneakers
2
+ class Publisher
3
+ def initialize(opts = {})
4
+ @mutex = Mutex.new
5
+ @opts = Sneakers::CONFIG.merge(opts)
6
+ end
7
+
8
+ def publish(msg, options = {})
9
+ @mutex.synchronize do
10
+ ensure_connection! unless connected?
11
+ end
12
+ to_queue = options.delete(:to_queue)
13
+ options[:routing_key] ||= to_queue
14
+ Sneakers.logger.info {"publishing <#{msg}> to [#{options[:routing_key]}]"}
15
+ @exchange.publish(msg, options)
16
+ end
17
+
18
+
19
+ attr_reader :exchange
20
+
21
+ private
22
+ def ensure_connection!
23
+ @bunny = Bunny.new(@opts[:amqp], heartbeat: @opts[:heartbeat], vhost: @opts[:vhost], :logger => Sneakers::logger)
24
+ @bunny.start
25
+ @channel = @bunny.create_channel
26
+ @exchange = @channel.exchange(@opts[:exchange], type: @opts[:exchange_type], durable: @opts[:durable])
27
+ end
28
+
29
+ def connected?
30
+ @bunny && @bunny.connected?
31
+ end
32
+ end
33
+ end
34
+
@@ -0,0 +1,65 @@
1
+
2
+ class Sneakers::Queue
3
+ attr_reader :name, :opts, :exchange
4
+
5
+ def initialize(name, opts)
6
+ @name = name
7
+ @opts = opts
8
+ @handler_klass = Sneakers::CONFIG[:handler]
9
+ end
10
+
11
+ #
12
+ # :exchange
13
+ # :heartbeat_interval
14
+ # :prefetch
15
+ # :durable
16
+ # :ack
17
+ #
18
+ def subscribe(worker)
19
+ #@bunny = Bunny.new(@opts[:amqp], :vhost => @opts[:vhost], :heartbeat => @opts[:heartbeat], :logger => Sneakers::logger)
20
+ @bunny = @opts[:connection]
21
+ @bunny ||= Bunny.new(@opts[:amqp], :vhost => @opts[:vhost], :heartbeat => @opts[:heartbeat], :logger => Sneakers::logger)
22
+ @bunny.start
23
+
24
+ @channel = @bunny.create_channel
25
+ @channel.prefetch(@opts[:prefetch])
26
+
27
+ exchange_name = @opts[:exchange]
28
+ @exchange = @channel.exchange(exchange_name,
29
+ :type => @opts[:exchange_type],
30
+ :durable => @opts[:durable])
31
+
32
+ routing_key = @opts[:routing_key] || @name
33
+ routing_keys = [*routing_key]
34
+
35
+ # TODO: get the arguments from the handler? Retry handler wants this so you
36
+ # don't have to line up the queue's dead letter argument with the exchange
37
+ # you'll create for retry.
38
+ queue_durable = @opts[:queue_durable].nil? ? @opts[:durable] : @opts[:queue_durable]
39
+ queue = @channel.queue(@name, :durable => queue_durable, :arguments => @opts[:arguments])
40
+
41
+ if exchange_name.length > 0
42
+ routing_keys.each do |key|
43
+ queue.bind(@exchange, :routing_key => key)
44
+ end
45
+ end
46
+
47
+ # NOTE: we are using the worker's options. This is necessary so the handler
48
+ # has the same configuration as the worker. Also pass along the exchange and
49
+ # queue in case the handler requires access to them (for things like binding
50
+ # retry queues, etc).
51
+ handler_klass = worker.opts[:handler] || Sneakers::CONFIG.fetch(:handler)
52
+ handler = handler_klass.new(@channel, queue, worker.opts)
53
+
54
+ @consumer = queue.subscribe(:block => false, :manual_ack => @opts[:ack]) do | delivery_info, metadata, msg |
55
+ worker.do_work(delivery_info, metadata, msg, handler)
56
+ end
57
+ nil
58
+ end
59
+
60
+ def unsubscribe
61
+ # XXX can we cancel bunny and channel too?
62
+ @consumer.cancel if @consumer
63
+ @consumer = nil
64
+ end
65
+ end
@@ -0,0 +1,82 @@
1
+ require 'serverengine'
2
+ require 'sneakers/workergroup'
3
+
4
+ module Sneakers
5
+ class Runner
6
+ def initialize(worker_classes, opts={})
7
+ @runnerconfig = RunnerConfig.new(worker_classes, opts)
8
+ end
9
+
10
+ def run
11
+ @se = ServerEngine.create(nil, WorkerGroup) { @runnerconfig.reload_config! }
12
+ @se.run
13
+ end
14
+
15
+ def stop
16
+ @se.stop
17
+ end
18
+ end
19
+
20
+
21
+ class RunnerConfig
22
+ def method_missing(meth, *args, &block)
23
+ if %w{ before_fork after_fork }.include? meth.to_s
24
+ @conf[meth] = block
25
+ elsif %w{ workers start_worker_delay amqp }.include? meth.to_s
26
+ @conf[meth] = args.first
27
+ else
28
+ super
29
+ end
30
+ end
31
+
32
+ def initialize(worker_classes, opts)
33
+ @worker_classes = worker_classes
34
+ @conf = opts
35
+ end
36
+
37
+ def to_h
38
+ @conf
39
+ end
40
+
41
+
42
+ def reload_config!
43
+ Sneakers.logger.warn("Loading runner configuration...")
44
+ config_file = Sneakers::CONFIG[:runner_config_file]
45
+
46
+ if config_file
47
+ begin
48
+ instance_eval(File.read(config_file), config_file)
49
+ Sneakers.logger.info("Loading config with file: #{config_file}")
50
+ rescue
51
+ Sneakers.logger.error("Cannot load from file '#{config_file}', #{$!}")
52
+ end
53
+ end
54
+
55
+ config = make_serverengine_config
56
+
57
+ [:before_fork, :after_fork].each do | hook |
58
+ Sneakers::CONFIG[:hooks][hook] = config.delete(hook) if config[hook]
59
+ end
60
+
61
+
62
+ Sneakers.logger.info("New configuration: #{config.inspect}")
63
+ config
64
+ end
65
+
66
+ private
67
+ def make_serverengine_config
68
+ # From Sneakers#setup_general_logger, there's support for a Logger object
69
+ # in CONFIG[:log]. However, serverengine takes an object in :logger.
70
+ # Pass our logger object so there's no issue about sometimes passing a
71
+ # file and sometimes an object.
72
+ without_log = Sneakers::CONFIG.merge(@conf)
73
+ without_log.delete(:log)
74
+ Sneakers::CONFIG.merge(@conf).merge({
75
+ :logger => Sneakers.logger,
76
+ :worker_type => 'process',
77
+ :worker_classes => @worker_classes
78
+ })
79
+ end
80
+ end
81
+
82
+ end