sneakers_custom_bunny 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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