sneakers 0.1.1.pre → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 9d39e1dc0f9c583d63d7ba5d6cf8078efe94f93f
4
+ data.tar.gz: 7bf1d48e4a492d454db3afc7474b9053adc05531
5
+ SHA512:
6
+ metadata.gz: d7c50e77e8a9de0e4ebed4d6120e78c5b5438d4d430ed776a686a69e6a288f000a4a1f40cf9a0bb5ab861bc7e5cb8fc93a5d8241301f15888b5ac3682447ae58
7
+ data.tar.gz: 80942f71ebc85d9365ada65413ec16f0f3444631e568f930de6279d6924301f74745336449e72a7da1963b01e0945fa0d155654164b7f3af44e22c9b6851def0
data/.gitignore CHANGED
@@ -1,6 +1,9 @@
1
1
  sneakers.yaml
2
+ Gemfile.lock
2
3
  *.log
3
4
  sneakers.pid
4
5
  pkg/
5
6
  coverage/
6
7
  tmp/
8
+ .ruby-version
9
+ .ruby-gemset
data/LICENSE.txt CHANGED
@@ -1,4 +1,4 @@
1
- Copyright (c) 2013 Dotan Nahum
1
+ Copyright (c) 2013-2014 Dotan Nahum
2
2
 
3
3
  MIT License
4
4
 
@@ -19,4 +19,4 @@ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
19
  NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
20
  LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
21
  OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
- WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/Rakefile CHANGED
@@ -7,4 +7,5 @@ Rake::TestTask.new do |t|
7
7
  t.test_files = FileList['spec/**/*_spec.rb']
8
8
  end
9
9
 
10
+ task default: :test
10
11
 
@@ -0,0 +1,78 @@
1
+ $: << File.expand_path('../lib', File.dirname(__FILE__))
2
+ require 'sneakers'
3
+ require 'sneakers/runner'
4
+ require 'sneakers/handlers/maxretry'
5
+ require 'logger'
6
+
7
+ Sneakers.configure(:handler => Sneakers::Handlers::Maxretry,
8
+ :workers => 1,
9
+ :threads => 1,
10
+ :prefetch => 1,
11
+ :exchange => 'sneakers',
12
+ :exchange_type => 'topic',
13
+ :routing_key => ['#', 'something'],
14
+ :durable => true,
15
+ )
16
+ Sneakers.logger.level = Logger::DEBUG
17
+
18
+ WORKER_OPTIONS = {
19
+ :ack => true,
20
+ :threads => 1,
21
+ :prefetch => 1,
22
+ :timeout_job_after => 60,
23
+ :heartbeat => 5,
24
+ :retry_timeout => 5000
25
+ }
26
+
27
+ # Example of how to write a retry worker. If your rabbit system is empty, then
28
+ # you must run this twice. Once to setup the exchanges, queues and bindings a
29
+ # second time to have the sent message end up on the downloads queue.
30
+ #
31
+ # Run this via:
32
+ # bundle exec ruby examples/max_retry_handler.rb
33
+ #
34
+ class MaxRetryWorker
35
+ include Sneakers::Worker
36
+ from_queue 'downloads',
37
+ WORKER_OPTIONS.merge({
38
+ :arguments => {
39
+ :'x-dead-letter-exchange' => 'downloads-retry'
40
+ },
41
+ })
42
+
43
+ def work(msg)
44
+ logger.info("MaxRetryWorker rejecting msg: #{msg.inspect}")
45
+
46
+ # We always want to reject to see if we do the proper timeout
47
+ reject!
48
+ end
49
+ end
50
+
51
+ # Example of a worker on the same exchange that does not fail, so it should only
52
+ # see the message once.
53
+ class SucceedingWorker
54
+ include Sneakers::Worker
55
+ from_queue 'uploads',
56
+ WORKER_OPTIONS.merge({
57
+ :arguments => {
58
+ :'x-dead-letter-exchange' => 'uploads-retry'
59
+ },
60
+ })
61
+
62
+ def work(msg)
63
+ logger.info("SucceedingWorker succeeding on msg: #{msg.inspect}")
64
+ ack!
65
+ end
66
+ end
67
+
68
+ messages = 1
69
+ puts "feeding messages in"
70
+ messages.times {
71
+ Sneakers.publish(" -- message -- ",
72
+ :to_queue => 'anywhere',
73
+ :persistence => true)
74
+ }
75
+ puts "done"
76
+
77
+ r = Sneakers::Runner.new([MaxRetryWorker, SucceedingWorker])
78
+ r.run
@@ -0,0 +1,40 @@
1
+ $: << File.expand_path('../lib', File.dirname(__FILE__))
2
+ require 'sneakers'
3
+ require 'sneakers/runner'
4
+ require 'sneakers/metrics/newrelic_metrics'
5
+ require 'open-uri'
6
+ require 'nokogiri'
7
+ require 'newrelic_rpm'
8
+
9
+ # With this configuration will send two types of data to newrelic server:
10
+ # 1. Transaction data which you would see under 'Applications'
11
+ # 2. Metrics where you will be able to see by configuring a dashboardi, available for enterprise accounts
12
+ #
13
+ # You should have newrelic.yml in the 'config' folder with the proper account settings
14
+
15
+ Sneakers::Metrics::NewrelicMetrics.eagent ::NewRelic
16
+ Sneakers.configure metrics: Sneakers::Metrics::NewrelicMetrics.new
17
+
18
+ class MetricsWorker
19
+ include Sneakers::Worker
20
+ include ::NewRelic::Agent::Instrumentation::ControllerInstrumentation
21
+
22
+ from_queue 'downloads'
23
+
24
+ def work(msg)
25
+ doc = Nokogiri::HTML(open(msg))
26
+ logger.info "FOUND <#{doc.css('title').text}>"
27
+ ack!
28
+ end
29
+
30
+ add_transaction_tracer :work, name: 'MetricsWorker', params: 'args[0]'
31
+
32
+ end
33
+
34
+
35
+ r = Sneakers::Runner.new([ MetricsWorker ])
36
+ r.run
37
+
38
+
39
+
40
+
data/lib/sneakers/cli.rb CHANGED
@@ -66,11 +66,11 @@ module Sneakers
66
66
 
67
67
  r = Sneakers::Runner.new(workers)
68
68
 
69
- pid = Sneakers::Config[:pid_path]
69
+ pid = Sneakers::CONFIG[:pid_path]
70
70
 
71
71
  say SNEAKERS
72
72
  say "Workers ....: #{em workers.join(', ')}"
73
- say "Log ........: #{em (Sneakers::Config[:log] == STDOUT ? 'Console' : Sneakers::Config[:log]) }"
73
+ say "Log ........: #{em (Sneakers::CONFIG[:log] == STDOUT ? 'Console' : Sneakers::CONFIG[:log]) }"
74
74
  say "PID ........: #{em pid}"
75
75
  say ""
76
76
  say (" "*31)+"Process control"
@@ -86,7 +86,7 @@ module Sneakers
86
86
 
87
87
  if options[:debug]
88
88
  say "==== configuration ==="
89
- say Sneakers::Config.inspect
89
+ say Sneakers::CONFIG.inspect
90
90
  say "======================"
91
91
  end
92
92
 
@@ -23,7 +23,7 @@ module Sneakers
23
23
  else
24
24
  @logger = Logger.new(STDOUT)
25
25
  @logger.level = Logger::INFO
26
- @logger.formatter = ProductionFormatter
26
+ @logger.formatter = Sneakers::Support::ProductionFormatter
27
27
  end
28
28
  end
29
29
  end
@@ -0,0 +1,47 @@
1
+ module Sneakers
2
+ class Configuration
3
+
4
+ extend Forwardable
5
+ def_delegators :@hash, :to_hash, :[], :[]=, :merge!, :==, :fetch, :delete
6
+
7
+ DEFAULTS = {
8
+ # runner
9
+ :runner_config_file => nil,
10
+ :metrics => nil,
11
+ :daemonize => false,
12
+ :start_worker_delay => 0.2,
13
+ :workers => 4,
14
+ :log => STDOUT,
15
+ :pid_path => 'sneakers.pid',
16
+
17
+ # workers
18
+ :timeout_job_after => 5,
19
+ :prefetch => 10,
20
+ :threads => 10,
21
+ :durable => true,
22
+ :ack => true,
23
+ :heartbeat => 2,
24
+ :exchange => 'sneakers',
25
+ :exchange_type => :direct,
26
+ :hooks => {}
27
+ }.freeze
28
+
29
+
30
+ def initialize
31
+ clear
32
+ end
33
+
34
+ def clear
35
+ @hash = DEFAULTS.dup
36
+ @hash[:amqp] = ENV.fetch('RABBITMQ_URL', 'amqp://guest:guest@localhost:5672')
37
+ @hash[:vhost] = AMQ::Settings.parse_amqp_url(@hash[:amqp]).fetch(:vhost, '/')
38
+ end
39
+
40
+ def merge(hash)
41
+ instance = self.class.new
42
+ instance.merge! to_hash
43
+ instance.merge! hash
44
+ instance
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,183 @@
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
+ if reason.backtrace
145
+ hash[:backtrace] = reason.backtrace.take(10).join(', ')
146
+ end
147
+ end
148
+ end.to_json
149
+ @error_exchange.publish(data, :routing_key => hdr.routing_key)
150
+ @channel.acknowledge(hdr.delivery_tag, false)
151
+ # TODO: metrics
152
+ end
153
+ end
154
+ private :handle_retry
155
+
156
+ # Uses the x-death header to determine the number of failures this job has
157
+ # seen in the past. This does not count the current failure. So for
158
+ # instance, the first time the job fails, this will return 0, the second
159
+ # time, 1, etc.
160
+ # @param headers [Hash] Hash of headers that Rabbit delivers as part of
161
+ # the message
162
+ # @return [Integer] Count of number of failures.
163
+ def failure_count(headers)
164
+ if headers.nil? || headers['x-death'].nil?
165
+ 0
166
+ else
167
+ headers['x-death'].select do |x_death|
168
+ x_death['queue'] == @worker_queue_name
169
+ end.count
170
+ end
171
+ end
172
+ private :failure_count
173
+
174
+ # Prefix all of our log messages so they are easier to find. We don't have
175
+ # the worker, so the next best thing is the queue name.
176
+ def log_prefix
177
+ "Maxretry handler [queue=#{@worker_queue_name}]"
178
+ end
179
+ private :log_prefix
180
+
181
+ end
182
+ end
183
+ end
@@ -1,27 +1,28 @@
1
1
  module Sneakers
2
2
  module Handlers
3
3
  class Oneshot
4
- def initialize(channel)
4
+ def initialize(channel, queue, opts)
5
5
  @channel = channel
6
+ @opts = opts
6
7
  end
7
8
 
8
- def acknowledge(tag)
9
- @channel.acknowledge(tag, false)
9
+ def acknowledge(hdr, props, msg)
10
+ @channel.acknowledge(hdr.delivery_tag, false)
10
11
  end
11
12
 
12
- def reject(tag, requeue=false)
13
- @channel.reject(tag, requeue)
13
+ def reject(hdr, props, msg, requeue=false)
14
+ @channel.reject(hdr.delivery_tag, requeue)
14
15
  end
15
16
 
16
- def error(tag, err)
17
- reject(tag)
17
+ def error(hdr, props, msg, err)
18
+ reject(hdr, props, msg)
18
19
  end
19
20
 
20
- def timeout(tag)
21
- reject(tag)
21
+ def timeout(hdr, props, msg)
22
+ reject(hdr, props, msg)
22
23
  end
23
24
 
24
- def noop(tag)
25
+ def noop(hdr, props, msg)
25
26
 
26
27
  end
27
28
  end
@@ -1,28 +1,29 @@
1
1
  module Sneakers
2
2
  class Publisher
3
- attr_accessor :exchange
4
-
5
- def initialize(opts={})
3
+ def initialize(opts = {})
6
4
  @mutex = Mutex.new
7
- @opts = Sneakers::Config.merge(opts)
5
+ @opts = Sneakers::CONFIG.merge(opts)
8
6
  end
9
7
 
10
- def publish(msg, routing)
8
+ def publish(msg, options = {})
11
9
  @mutex.synchronize do
12
10
  ensure_connection! unless connected?
13
11
  end
14
- Sneakers.logger.info("publishing <#{msg}> to [#{routing[:to_queue]}]")
15
- @exchange.publish(msg, :routing_key => routing[:to_queue])
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
16
  end
17
17
 
18
+ private
18
19
 
19
- private
20
+ attr_reader :exchange
20
21
 
21
22
  def ensure_connection!
22
- @bunny = Bunny.new(@opts[:amqp], :heartbeat => @opts[:heartbeat])
23
+ @bunny = Bunny.new(@opts[:amqp], heartbeat: @opts[:heartbeat], vhost: @opts[:vhost], :logger => Sneakers::logger)
23
24
  @bunny.start
24
25
  @channel = @bunny.create_channel
25
- @exchange = @channel.exchange(@opts[:exchange], :type => @opts[:exchange_type], :durable => @opts[:durable])
26
+ @exchange = @channel.exchange(@opts[:exchange], type: @opts[:exchange_type], durable: @opts[:durable])
26
27
  end
27
28
 
28
29
  def connected?
@@ -5,7 +5,7 @@ class Sneakers::Queue
5
5
  def initialize(name, opts)
6
6
  @name = name
7
7
  @opts = opts
8
- @handler_klass = Sneakers::Config[:handler]
8
+ @handler_klass = Sneakers::CONFIG[:handler]
9
9
  end
10
10
 
11
11
  #
@@ -16,7 +16,7 @@ class Sneakers::Queue
16
16
  # :ack
17
17
  #
18
18
  def subscribe(worker)
19
- @bunny = Bunny.new(@opts[:amqp], :vhost => @opts[:vhost], :heartbeat => @opts[:heartbeat])
19
+ @bunny = Bunny.new(@opts[:amqp], :vhost => @opts[:vhost], :heartbeat => @opts[:heartbeat], :logger => Sneakers::logger)
20
20
  @bunny.start
21
21
 
22
22
  @channel = @bunny.create_channel
@@ -26,19 +26,28 @@ class Sneakers::Queue
26
26
  :type => @opts[:exchange_type],
27
27
  :durable => @opts[:durable])
28
28
 
29
- handler = @handler_klass.new(@channel)
30
-
31
29
  routing_key = @opts[:routing_key] || @name
32
30
  routing_keys = [*routing_key]
33
31
 
34
- queue = @channel.queue(@name, :durable => @opts[:durable])
32
+ # TODO: get the arguments from the handler? Retry handler wants this so you
33
+ # don't have to line up the queue's dead letter argument with the exchange
34
+ # you'll create for retry.
35
+ queue_durable = @opts[:queue_durable].nil? ? @opts[:durable] : @opts[:queue_durable]
36
+ queue = @channel.queue(@name, :durable => queue_durable, :arguments => @opts[:arguments])
35
37
 
36
38
  routing_keys.each do |key|
37
39
  queue.bind(@exchange, :routing_key => key)
38
40
  end
39
41
 
40
- @consumer = queue.subscribe(:block => false, :ack => @opts[:ack]) do | hdr, props, msg |
41
- worker.do_work(hdr, props, msg, handler)
42
+ # NOTE: we are using the worker's options. This is necessary so the handler
43
+ # has the same configuration as the worker. Also pass along the exchange and
44
+ # queue in case the handler requires access to them (for things like binding
45
+ # retry queues, etc).
46
+ handler_klass = worker.opts[:handler] || Sneakers::CONFIG[:handler]
47
+ handler = handler_klass.new(@channel, queue, worker.opts)
48
+
49
+ @consumer = queue.subscribe(:block => false, :ack => @opts[:ack]) do | delivery_info, metadata, msg |
50
+ worker.do_work(delivery_info, metadata, msg, handler)
42
51
  end
43
52
  nil
44
53
  end
@@ -41,7 +41,7 @@ module Sneakers
41
41
 
42
42
  def reload_config!
43
43
  Sneakers.logger.warn("Loading runner configuration...")
44
- config_file = Sneakers::Config[:runner_config_file]
44
+ config_file = Sneakers::CONFIG[:runner_config_file]
45
45
 
46
46
  if config_file
47
47
  begin
@@ -55,7 +55,7 @@ module Sneakers
55
55
  config = make_serverengine_config
56
56
 
57
57
  [:before_fork, :after_fork].each do | hook |
58
- Sneakers::Config[:hooks][hook] = config.delete(hook) if config[hook]
58
+ Sneakers::CONFIG[:hooks][hook] = config.delete(hook) if config[hook]
59
59
  end
60
60
 
61
61
 
@@ -65,11 +65,11 @@ module Sneakers
65
65
 
66
66
  private
67
67
  def make_serverengine_config
68
- Sneakers::Config.merge(@conf).merge({
68
+ Sneakers::CONFIG.merge(@conf).merge({
69
69
  :worker_type => 'process',
70
70
  :worker_classes => @worker_classes
71
71
  })
72
72
  end
73
73
  end
74
-
74
+
75
75
  end
@@ -1,3 +1,3 @@
1
1
  module Sneakers
2
- VERSION = "0.1.1.pre"
2
+ VERSION = "1.0.0"
3
3
  end