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.
- checksums.yaml +7 -0
- data/.gitignore +2 -0
- data/Gemfile +18 -0
- data/Guardfile +5 -0
- data/README.md +136 -0
- data/Rakefile +14 -0
- data/bin/hutch +8 -0
- data/circle.yml +3 -0
- data/examples/consumer.rb +13 -0
- data/examples/producer.rb +10 -0
- data/hutch.gemspec +22 -0
- data/lib/hutch.rb +40 -0
- data/lib/hutch/broker.rb +175 -0
- data/lib/hutch/cli.rb +151 -0
- data/lib/hutch/config.rb +66 -0
- data/lib/hutch/consumer.rb +33 -0
- data/lib/hutch/error_handlers/logger.rb +16 -0
- data/lib/hutch/error_handlers/sentry.rb +23 -0
- data/lib/hutch/exceptions.rb +5 -0
- data/lib/hutch/logging.rb +32 -0
- data/lib/hutch/message.rb +26 -0
- data/lib/hutch/version.rb +4 -0
- data/lib/hutch/worker.rb +104 -0
- data/spec/hutch/broker_spec.rb +157 -0
- data/spec/hutch/config_spec.rb +69 -0
- data/spec/hutch/consumer_spec.rb +80 -0
- data/spec/hutch/error_handlers/logger_spec.rb +15 -0
- data/spec/hutch/error_handlers/sentry_spec.rb +20 -0
- data/spec/hutch/logger_spec.rb +28 -0
- data/spec/hutch/message_spec.rb +35 -0
- data/spec/hutch/worker_spec.rb +80 -0
- data/spec/hutch_spec.rb +16 -0
- data/spec/spec_helper.rb +23 -0
- metadata +144 -0
data/lib/hutch/cli.rb
ADDED
@@ -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
|
+
|
data/lib/hutch/config.rb
ADDED
@@ -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,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
|
+
|
data/lib/hutch/worker.rb
ADDED
@@ -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
|