hutch 0.1.0

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.
@@ -0,0 +1,151 @@
1
+ require 'optparse'
2
+
3
+ require 'hutch/logging'
4
+ require 'hutch/exceptions'
5
+
6
+ module Hutch
7
+ class CLI
8
+ include Logging
9
+
10
+ # Run a Hutch worker with the command line interface.
11
+ def run
12
+ parse_options
13
+
14
+ Hutch.logger.info "hutch booted with pid #{Process.pid}"
15
+
16
+ if load_app && start_work_loop == :success
17
+ # If we got here, the worker was shut down nicely
18
+ Hutch.logger.info 'hutch shut down gracefully'
19
+ exit 0
20
+ else
21
+ Hutch.logger.info 'hutch terminated due to an error'
22
+ exit 1
23
+ end
24
+ end
25
+
26
+ def load_app
27
+ # Try to load a Rails app in the current directory
28
+ load_rails_app('.')
29
+ Hutch::Config.require_paths.each do |path|
30
+ # See if each path is a Rails app. If so, try to load it.
31
+ next if load_rails_app(path)
32
+
33
+ # Given path is not a Rails app, try requiring it as a file
34
+ logger.info "requiring '#{path}'"
35
+ begin
36
+ # Need to add '.' to load path for relative requires
37
+ $LOAD_PATH << '.'
38
+ require path
39
+ rescue LoadError
40
+ logger.fatal "could not load file '#{path}'"
41
+ return false
42
+ ensure
43
+ # Clean up load path
44
+ $LOAD_PATH.pop
45
+ end
46
+ end
47
+
48
+ # Because of the order things are required when we run the Hutch binary
49
+ # in hutch/bin, the Sentry Raven gem gets required **after** the error
50
+ # handlers are set up. Due to this, we never got any Sentry notifications
51
+ # when an error occurred in any of the consumers.
52
+ if defined?(Raven)
53
+ Hutch::Config[:error_handlers] << Hutch::ErrorHandlers::Sentry.new
54
+ end
55
+
56
+ true
57
+ end
58
+
59
+ def load_rails_app(path)
60
+ # path should point to the app's top level directory
61
+ if File.directory?(path)
62
+ # Smells like a Rails app if it's got a config/environment.rb file
63
+ rails_path = File.expand_path(File.join(path, 'config/environment.rb'))
64
+ if File.exists?(rails_path)
65
+ logger.info "found rails project (#{path}), booting app"
66
+ ENV['RACK_ENV'] ||= ENV['RAILS_ENV'] || 'development'
67
+ require rails_path
68
+ ::Rails.application.eager_load!
69
+ return true
70
+ end
71
+ end
72
+ false
73
+ end
74
+
75
+ # Kick off the work loop. This method returns when the worker is shut down
76
+ # gracefully (with a SIGQUIT, SIGTERM or SIGINT).
77
+ def start_work_loop
78
+ Hutch.connect
79
+ @worker = Hutch::Worker.new(Hutch.broker, Hutch.consumers)
80
+ @worker.run
81
+ :success
82
+ rescue ConnectionError, AuthenticationError, WorkerSetupError => ex
83
+ logger.fatal ex.message
84
+ :error
85
+ end
86
+
87
+ def parse_options
88
+ OptionParser.new do |opts|
89
+ opts.banner = 'usage: hutch [options]'
90
+
91
+ opts.on('--mq-host HOST', 'Set the RabbitMQ host') do |host|
92
+ Hutch::Config.mq_host = host
93
+ end
94
+
95
+ opts.on('--mq-port PORT', 'Set the RabbitMQ port') do |port|
96
+ Hutch::Config.mq_port = port
97
+ end
98
+
99
+ opts.on('--mq-exchange EXCHANGE',
100
+ 'Set the RabbitMQ exchange') do |exchange|
101
+ Hutch::Config.mq_exchange = exchange
102
+ end
103
+
104
+ opts.on('--mq-vhost VHOST', 'Set the RabbitMQ vhost') do |vhost|
105
+ Hutch::Config.mq_vhost = vhost
106
+ end
107
+
108
+ opts.on('--mq-username USERNAME',
109
+ 'Set the RabbitMQ username') do |username|
110
+ Hutch::Config.mq_username = username
111
+ end
112
+
113
+ opts.on('--mq-password PASSWORD',
114
+ 'Set the RabbitMQ password') do |password|
115
+ Hutch::Config.mq_password = password
116
+ end
117
+
118
+ opts.on('--mq-api-host HOST', 'Set the RabbitMQ API host') do |host|
119
+ Hutch::Config.mq_api_host = host
120
+ end
121
+
122
+ opts.on('--mq-api-port PORT', 'Set the RabbitMQ API port') do |port|
123
+ Hutch::Config.mq_api_port = port
124
+ end
125
+
126
+ opts.on('--require PATH', 'Require a Rails app or path') do |path|
127
+ Hutch::Config.require_paths << path
128
+ end
129
+
130
+ opts.on('-q', '--quiet', 'Quiet logging') do
131
+ Hutch::Config.log_level = Logger::WARN
132
+ end
133
+
134
+ opts.on('-v', '--verbose', 'Verbose logging') do
135
+ Hutch::Config.log_level = Logger::DEBUG
136
+ end
137
+
138
+ opts.on('--version', 'Print the version and exit') do
139
+ puts "hutch v#{VERSION}"
140
+ exit 0
141
+ end
142
+
143
+ opts.on('-h', '--help', 'Show this message and exit') do
144
+ puts opts
145
+ exit 0
146
+ end
147
+ end.parse!
148
+ end
149
+ end
150
+ end
151
+
@@ -0,0 +1,66 @@
1
+ require 'logger'
2
+
3
+ module Hutch
4
+ class UnknownAttributeError < StandardError; end
5
+
6
+ module Config
7
+ def self.initialize
8
+ @config = {
9
+ mq_host: 'localhost',
10
+ mq_port: 5672,
11
+ mq_exchange: 'hutch', # TODO: should this be required?
12
+ mq_vhost: '/',
13
+ mq_username: 'guest',
14
+ mq_password: 'guest',
15
+ mq_api_host: 'localhost',
16
+ mq_api_port: 15672,
17
+ log_level: Logger::INFO,
18
+ require_paths: [],
19
+ error_handlers: [Hutch::ErrorHandlers::Logger.new]
20
+ }
21
+ end
22
+
23
+ def self.get(attr)
24
+ check_attr(attr)
25
+ user_config[attr]
26
+ end
27
+
28
+ def self.set(attr, value)
29
+ check_attr(attr)
30
+ user_config[attr] = value
31
+ end
32
+
33
+ class << self
34
+ alias_method :[], :get
35
+ alias_method :[]=, :set
36
+ end
37
+
38
+ def self.check_attr(attr)
39
+ unless user_config.key?(attr)
40
+ raise UnknownAttributeError, "#{attr} is not a valid config attribute"
41
+ end
42
+ end
43
+
44
+ def self.user_config
45
+ initialize unless @config
46
+ @config
47
+ end
48
+
49
+ def self.method_missing(method, *args, &block)
50
+ attr = method.to_s.sub(/=$/, '').to_sym
51
+ return super unless user_config.key?(attr)
52
+
53
+ if method =~ /=$/
54
+ set(attr, args.first)
55
+ else
56
+ get(attr)
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def deep_copy(obj)
63
+ Marshal.load(Marshal.dump(obj))
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,33 @@
1
+ module Hutch
2
+ # Include this module in a class to register it as a consumer. Consumers
3
+ # gain a class method called `consume`, which should be used to register
4
+ # the routing keys a consumer is interested in.
5
+ module Consumer
6
+ def self.included(base)
7
+ base.extend(ClassMethods)
8
+ Hutch.register_consumer(base)
9
+ end
10
+
11
+ module ClassMethods
12
+ # Add one or more routing keys to the set of routing keys the consumer
13
+ # wants to subscribe to.
14
+ def consume(*routing_keys)
15
+ @routing_keys = self.routing_keys.union(routing_keys)
16
+ end
17
+
18
+ # The RabbitMQ queue name for the consumer. This is derived from the
19
+ # fully-qualified class name. Module separators are replaced with single
20
+ # colons, camelcased class names are converted to snake case.
21
+ def queue_name
22
+ queue_name = self.name.gsub(/::/, ':')
23
+ queue_name.gsub(/([^A-Z:])([A-Z])/) { "#{$1}_#{$2}" }.downcase
24
+ end
25
+
26
+ # Accessor for the consumer's routing key.
27
+ def routing_keys
28
+ @routing_keys ||= Set.new
29
+ end
30
+ end
31
+ end
32
+ end
33
+
@@ -0,0 +1,16 @@
1
+ require 'hutch/logging'
2
+
3
+ module Hutch
4
+ module ErrorHandlers
5
+ class Logger
6
+ include Logging
7
+
8
+ def handle(message_id, consumer, ex)
9
+ prefix = "message(#{message_id || '-'}): "
10
+ logger.error prefix + "error in consumer '#{consumer}'"
11
+ logger.error prefix + "#{ex.class} - #{ex.message}"
12
+ logger.error (['backtrace:'] + ex.backtrace).join("\n")
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,23 @@
1
+ require 'hutch/logging'
2
+ require 'raven'
3
+
4
+ module Hutch
5
+ module ErrorHandlers
6
+ class Sentry
7
+ include Logging
8
+
9
+ def initialize
10
+ unless Raven.respond_to?(:capture_exception)
11
+ raise "The Hutch Sentry error handler requires Raven >= 0.4.0"
12
+ end
13
+ end
14
+
15
+ def handle(message_id, consumer, ex)
16
+ prefix = "message(#{message_id || '-'}): "
17
+ logger.error prefix + "Logging event to Sentry"
18
+ logger.error prefix + "#{ex.class} - #{ex.message}"
19
+ Raven.capture_exception(ex)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,5 @@
1
+ module Hutch
2
+ class ConnectionError < StandardError; end
3
+ class AuthenticationError < StandardError; end
4
+ class WorkerSetupError < StandardError; end
5
+ end
@@ -0,0 +1,32 @@
1
+ require 'logger'
2
+ require 'time'
3
+ require 'hutch/config'
4
+
5
+ module Hutch
6
+ module Logging
7
+ class HutchFormatter < Logger::Formatter
8
+ def call(severity, time, program_name, message)
9
+ "#{time.utc.iso8601} #{Process.pid} #{severity} -- #{message}\n"
10
+ end
11
+ end
12
+
13
+ def self.setup_logger(target = $stdout)
14
+ @logger = Logger.new(target)
15
+ @logger.level = Hutch::Config.log_level
16
+ @logger.formatter = HutchFormatter.new
17
+ @logger
18
+ end
19
+
20
+ def self.logger
21
+ @logger || setup_logger
22
+ end
23
+
24
+ def self.logger=(logger)
25
+ @logger = logger
26
+ end
27
+
28
+ def logger
29
+ Hutch::Logging.logger
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,26 @@
1
+ require 'multi_json'
2
+ require 'forwardable'
3
+
4
+ module Hutch
5
+ class Message
6
+ extend Forwardable
7
+
8
+ def initialize(delivery_info, properties, payload)
9
+ @delivery_info = delivery_info
10
+ @properties = properties
11
+ @payload = payload
12
+ @body = MultiJson.load(payload, symbolize_keys: true)
13
+ end
14
+
15
+ def_delegator :@body, :[]
16
+ def_delegators :@properties, :message_id, :timestamp
17
+ def_delegators :@delivery_info, :routing_key, :exchange
18
+
19
+ attr_reader :body
20
+
21
+ def to_s
22
+ "#<Message #{body.map { |k,v| "#{k}: #{v.inspect}" }.join(', ')}>"
23
+ end
24
+ end
25
+ end
26
+
@@ -0,0 +1,4 @@
1
+ module Hutch
2
+ VERSION = '0.1.0'.freeze
3
+ end
4
+
@@ -0,0 +1,104 @@
1
+ require 'hutch/message'
2
+ require 'hutch/logging'
3
+ require 'hutch/broker'
4
+ require 'carrot-top'
5
+
6
+ module Hutch
7
+ class Worker
8
+ include Logging
9
+
10
+ def initialize(broker, consumers)
11
+ @broker = broker
12
+ raise WorkerSetupError.new('no consumers loaded') if consumers.empty?
13
+ @consumers = consumers
14
+ end
15
+
16
+ # Run the main event loop. The consumers will be set up with queues, and
17
+ # process the messages in their respective queues indefinitely. This method
18
+ # never returns.
19
+ def run
20
+ setup_queues
21
+
22
+ # Set up signal handlers for graceful shutdown
23
+ register_signal_handlers
24
+
25
+ # Take a break from Thread#join every 0.1 seconds to check if we've
26
+ # been sent any signals
27
+ handle_signals until @broker.wait_on_threads(0.1)
28
+ rescue Bunny::PreconditionFailed => ex
29
+ logger.error ex.message
30
+ raise WorkerSetupError.new('could not create queue due to a type ' +
31
+ 'conflict with an existing queue, remove ' +
32
+ 'the existing queue and try again')
33
+ end
34
+
35
+ # Register handlers for SIG{QUIT,TERM,INT} to shut down the worker
36
+ # gracefully. Forceful shutdowns are very bad!
37
+ def register_signal_handlers
38
+ Thread.main[:signal_queue] = []
39
+ %w(QUIT TERM INT).map(&:to_sym).each do |sig|
40
+ # This needs to be reentrant, so we queue up signals to be handled
41
+ # in the run loop, rather than acting on signals here
42
+ trap(sig) do
43
+ Thread.main[:signal_queue] << sig
44
+ end
45
+ end
46
+ end
47
+
48
+ # Handle any pending signals
49
+ def handle_signals
50
+ signal = Thread.main[:signal_queue].shift
51
+ if signal
52
+ logger.info "caught sig#{signal.downcase}, stopping hutch..."
53
+ stop
54
+ end
55
+ end
56
+
57
+ # Stop a running worker by killing all subscriber threads.
58
+ def stop
59
+ @broker.stop
60
+ end
61
+
62
+ # Set up the queues for each of the worker's consumers.
63
+ def setup_queues
64
+ logger.info 'setting up queues'
65
+ @consumers.each { |consumer| setup_queue(consumer) }
66
+ end
67
+
68
+ # Bind a consumer's routing keys to its queue, and set up a subscription to
69
+ # receive messages sent to the queue.
70
+ def setup_queue(consumer)
71
+ queue = @broker.queue(consumer.queue_name)
72
+ @broker.bind_queue(queue, consumer.routing_keys)
73
+
74
+ queue.subscribe(ack: true) do |delivery_info, properties, payload|
75
+ handle_message(consumer, delivery_info, properties, payload)
76
+ end
77
+ end
78
+
79
+ # Called internally when a new messages comes in from RabbitMQ. Responsible
80
+ # for wrapping up the message and passing it to the consumer.
81
+ def handle_message(consumer, delivery_info, properties, payload)
82
+ logger.info("message(#{properties.message_id || '-'}): " +
83
+ "routing key: #{delivery_info.routing_key}, " +
84
+ "consumer: #{consumer}, " +
85
+ "payload: #{payload}")
86
+
87
+ message = Message.new(delivery_info, properties, payload)
88
+ broker = @broker
89
+ begin
90
+ consumer.new.process(message)
91
+ broker.ack(delivery_info.delivery_tag)
92
+ rescue StandardError => ex
93
+ handle_error(message.message_id, consumer, ex)
94
+ broker.ack(delivery_info.delivery_tag)
95
+ end
96
+ end
97
+
98
+ def handle_error(message_id, consumer, ex)
99
+ Hutch::Config[:error_handlers].each do |backend|
100
+ backend.handle(message_id, consumer, ex)
101
+ end
102
+ end
103
+ end
104
+ end