hutch 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 41727b6727cb22516abf3d78004b4b079d7f4227
|
4
|
+
data.tar.gz: 7d6d4f335003d299b94c33c4bf297c8912932cbb
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: c7e22f63f21e5afbd94f503b4933b0f43522625e592fb1f2be357ca2728c7bba378db1761bf44690c5c0cf318fb898d6cfaae1eeb7870c964638fc73a78c3b00
|
7
|
+
data.tar.gz: 007464c716f89732b2ca924223e90d92db730f5ebb23b740efb8acbc9b7a00e4285d41f4fb50af4435078fd855d77ee0fd820ccce328294556887ce608759fe7
|
data/.gitignore
ADDED
data/Gemfile
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
source 'https://rubygems.org'
|
2
|
+
|
3
|
+
gemspec
|
4
|
+
|
5
|
+
group :development do
|
6
|
+
gem "rake"
|
7
|
+
gem "guard", "~> 0.8.8"
|
8
|
+
gem "guard-rspec", "~> 0.5.4"
|
9
|
+
end
|
10
|
+
|
11
|
+
group :development, :test do
|
12
|
+
gem "sentry-raven"
|
13
|
+
end
|
14
|
+
|
15
|
+
group :development, :darwin do
|
16
|
+
gem "rb-fsevent", "~> 0.9"
|
17
|
+
gem "growl", "~> 1.0.3"
|
18
|
+
end
|
data/Guardfile
ADDED
data/README.md
ADDED
@@ -0,0 +1,136 @@
|
|
1
|
+
![](http://cl.ly/image/3h0q3F3G142K/hutch.png)
|
2
|
+
|
3
|
+
Hutch is a Ruby library for enabling asynchronous inter-service communication
|
4
|
+
in a service-oriented architecture, using RabbitMQ.
|
5
|
+
|
6
|
+
|
7
|
+
## Defining Consumers
|
8
|
+
|
9
|
+
Consumers receive messages from a RabbitMQ queue. That queue may be bound to
|
10
|
+
one or more topics (represented by routing keys).
|
11
|
+
|
12
|
+
To create a consumer, include the `Hutch::Consumer` module in a class that
|
13
|
+
defines a `#process` method. `#process` should take a single argument, which
|
14
|
+
will be a `Message` object. The `Message` object encapsulates the message data,
|
15
|
+
along with any associated metadata. To access properties of the message, use
|
16
|
+
Hash-style indexing syntax:
|
17
|
+
|
18
|
+
```ruby
|
19
|
+
message[:id] # => "02ABCXYZ"
|
20
|
+
```
|
21
|
+
|
22
|
+
To subscribe to a topic, pass a routing key to `consume` in the class
|
23
|
+
definition. To bind to multiple routing keys, simply pass extra routing keys
|
24
|
+
in as additional arguments. Refer to the [RabbitMQ docs on topic exchanges
|
25
|
+
][topic-docs] for more information about how to use routing keys. Here's an
|
26
|
+
example consumer:
|
27
|
+
|
28
|
+
```ruby
|
29
|
+
class FailedPaymentConsumer
|
30
|
+
include Hutch::Consumer
|
31
|
+
consume 'gc.ps.payment.failed'
|
32
|
+
|
33
|
+
def process(message)
|
34
|
+
mark_payment_as_failed(message[:id])
|
35
|
+
end
|
36
|
+
end
|
37
|
+
```
|
38
|
+
|
39
|
+
If you are using Hutch with Rails and want to make Hutch log to the Rails
|
40
|
+
logger rather than `stdout`, add this to `config/initializers/hutch.rb`
|
41
|
+
|
42
|
+
```ruby
|
43
|
+
Hutch::Logging.logger = Rails.logger
|
44
|
+
```
|
45
|
+
|
46
|
+
[topic-docs]: http://www.rabbitmq.com/tutorials/tutorial-five-python.html
|
47
|
+
|
48
|
+
|
49
|
+
## Running Hutch
|
50
|
+
|
51
|
+
After installing the Hutch gem, you should be able to start it by simply
|
52
|
+
running `hutch` on the command line. `hutch` takes a number of options:
|
53
|
+
|
54
|
+
```console
|
55
|
+
$ hutch -h
|
56
|
+
usage: hutch [options]
|
57
|
+
--mq-host HOST Set the RabbitMQ host
|
58
|
+
--mq-port PORT Set the RabbitMQ port
|
59
|
+
--mq-exchange EXCHANGE Set the RabbitMQ exchange
|
60
|
+
--mq-vhost VHOST Set the RabbitMQ vhost
|
61
|
+
--mq-api-host HOST Set the RabbitMQ API host
|
62
|
+
--mq-api-port PORT Set the RabbitMQ API port
|
63
|
+
--mq-api-username USERNAME Set the RabbitMQ API username
|
64
|
+
--mq-api-password PASSWORD Set the RabbitMQ API password
|
65
|
+
--require PATH Require a Rails app or path
|
66
|
+
-q, --quiet Quiet logging
|
67
|
+
-v, --verbose Verbose logging
|
68
|
+
--version Print the version and exit
|
69
|
+
-h, --help Show this message and exit
|
70
|
+
```
|
71
|
+
|
72
|
+
The first three are for configuring which RabbitMQ instance to connect to.
|
73
|
+
`--require` is covered in the next section. The remainder are self-explanatory.
|
74
|
+
|
75
|
+
### Loading Consumers
|
76
|
+
|
77
|
+
Using Hutch with a Rails app is simple. Either start Hutch in the working
|
78
|
+
directory of a Rails app, or pass the path to a Rails app in with the
|
79
|
+
`--require` option. Consumers defined in Rails apps should be placed with in
|
80
|
+
the `app/consumers/` directory, to allow them to be auto-loaded when Rails
|
81
|
+
boots.
|
82
|
+
|
83
|
+
To require files that define consumers manually, simply pass each file as an
|
84
|
+
option to `--require`. Hutch will automatically detect whether you've provided
|
85
|
+
a Rails app or a standard file, and take the appropriate behaviour:
|
86
|
+
|
87
|
+
```bash
|
88
|
+
$ hutch --require path/to/rails-app # loads a rails app
|
89
|
+
$ hutch --require path/to/file.rb # loads a ruby file
|
90
|
+
```
|
91
|
+
|
92
|
+
## Producers
|
93
|
+
|
94
|
+
Hutch includes a `publish` method for sending messages to Hutch consumers. When
|
95
|
+
possible, this should be used, rather than directly interfacing with RabbitMQ
|
96
|
+
libraries.
|
97
|
+
|
98
|
+
```ruby
|
99
|
+
Hutch.publish('routing.key', subject: 'payment', action: 'received')
|
100
|
+
```
|
101
|
+
|
102
|
+
### Writing Well-Behaved Publishers
|
103
|
+
|
104
|
+
You may need to send messages to Hutch from languages other than Ruby. This
|
105
|
+
prevents the use of `Hutch.publish`, requiring custom publication code to be
|
106
|
+
written. There are a few things to keep in mind when writing producers that
|
107
|
+
send messages to Hutch.
|
108
|
+
|
109
|
+
- Make sure that the producer exchange name matches the exchange name that
|
110
|
+
Hutch is using.
|
111
|
+
- Hutch works with topic exchanges, check the producer is also using topic
|
112
|
+
exchanges.
|
113
|
+
- Use message routing keys that match those used in your Hutch consumers.
|
114
|
+
- Be sure your exchanges are marked as durable. In the Ruby AMQP gem, this is
|
115
|
+
done by passing `durable: true` to the exchange creation method.
|
116
|
+
- Mark your messages as persistent. This is done by passing `persistent: true`
|
117
|
+
to the publish method in Ruby AMQP.
|
118
|
+
- Wrapping publishing code in transactions or using publisher confirms is
|
119
|
+
highly recommended. This can be slightly tricky, see [this issue][pc-issue]
|
120
|
+
and [this gist][pc-gist] for more info.
|
121
|
+
|
122
|
+
Here's an example of a well-behaved publisher, minus publisher confirms:
|
123
|
+
|
124
|
+
```ruby
|
125
|
+
AMQP.connect(host: config[:host]) do |connection|
|
126
|
+
channel = AMQP::Channel.new(connection)
|
127
|
+
exchange = channel.topic(config[:exchange], durable: true)
|
128
|
+
|
129
|
+
message = JSON.dump({ subject: 'Test', id: 'abc' })
|
130
|
+
exchange.publish(message, routing_key: 'test', persistent: true)
|
131
|
+
end
|
132
|
+
```
|
133
|
+
|
134
|
+
[pc-issue]: https://github.com/ruby-amqp/amqp/issues/92
|
135
|
+
[pc-gist]: https://gist.github.com/3042381
|
136
|
+
|
data/Rakefile
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
require 'rspec/core/rake_task'
|
2
|
+
|
3
|
+
desc "Run an IRB session with Hutch pre-loaded"
|
4
|
+
task :console do
|
5
|
+
exec "irb -I lib -r hutch"
|
6
|
+
end
|
7
|
+
|
8
|
+
desc "Run the test suite"
|
9
|
+
RSpec::Core::RakeTask.new(:spec) do |t|
|
10
|
+
t.pattern = FileList['spec/**/*_spec.rb']
|
11
|
+
t.rspec_opts = %w(--color --format doc)
|
12
|
+
end
|
13
|
+
|
14
|
+
task :default => :spec
|
data/bin/hutch
ADDED
data/circle.yml
ADDED
data/hutch.gemspec
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
require File.expand_path('../lib/hutch/version', __FILE__)
|
2
|
+
|
3
|
+
Gem::Specification.new do |gem|
|
4
|
+
gem.add_runtime_dependency 'bunny', '0.10.6'
|
5
|
+
gem.add_runtime_dependency 'carrot-top', '~> 0.0.7'
|
6
|
+
gem.add_runtime_dependency 'multi_json', '~> 1.5'
|
7
|
+
gem.add_development_dependency 'rspec', '~> 2.12.0'
|
8
|
+
|
9
|
+
gem.name = 'hutch'
|
10
|
+
gem.summary = 'Easy inter-service communication using RabbitMQ.'
|
11
|
+
gem.description = 'Hutch is a Ruby library for enabling asynchronous ' +
|
12
|
+
'inter-service communication using RabbitMQ.'
|
13
|
+
gem.version = Hutch::VERSION.dup
|
14
|
+
gem.authors = ['Harry Marr']
|
15
|
+
gem.email = ['developers@gocardless.com']
|
16
|
+
gem.homepage = 'https://github.com/gocardless/hutch'
|
17
|
+
gem.require_paths = ['lib']
|
18
|
+
gem.license = 'MIT'
|
19
|
+
gem.executables = ['hutch']
|
20
|
+
gem.files = `git ls-files`.split("\n")
|
21
|
+
gem.test_files = `git ls-files -- spec/*`.split("\n")
|
22
|
+
end
|
data/lib/hutch.rb
ADDED
@@ -0,0 +1,40 @@
|
|
1
|
+
module Hutch
|
2
|
+
require 'hutch/consumer'
|
3
|
+
require 'hutch/worker'
|
4
|
+
require 'hutch/logging'
|
5
|
+
require 'hutch/error_handlers/logger'
|
6
|
+
ErrorHandlers.autoload :Sentry, 'hutch/error_handlers/sentry'
|
7
|
+
|
8
|
+
def self.register_consumer(consumer)
|
9
|
+
self.consumers << consumer
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.consumers
|
13
|
+
@consumers ||= []
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.logger
|
17
|
+
Hutch::Logging.logger
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.connect(config = Hutch::Config)
|
21
|
+
unless connected?
|
22
|
+
@broker = Hutch::Broker.new(config)
|
23
|
+
@broker.connect
|
24
|
+
@connected = true
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def self.broker
|
29
|
+
@broker
|
30
|
+
end
|
31
|
+
|
32
|
+
def self.connected?
|
33
|
+
@connected
|
34
|
+
end
|
35
|
+
|
36
|
+
def self.publish(routing_key, message)
|
37
|
+
@broker.publish(routing_key, message)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
data/lib/hutch/broker.rb
ADDED
@@ -0,0 +1,175 @@
|
|
1
|
+
require 'bunny'
|
2
|
+
require 'carrot-top'
|
3
|
+
require 'securerandom'
|
4
|
+
require 'hutch/logging'
|
5
|
+
require 'hutch/exceptions'
|
6
|
+
|
7
|
+
module Hutch
|
8
|
+
class Broker
|
9
|
+
include Logging
|
10
|
+
|
11
|
+
attr_accessor :connection, :channel, :exchange, :api_client
|
12
|
+
|
13
|
+
def initialize(config = nil)
|
14
|
+
@config = config || Hutch::Config
|
15
|
+
end
|
16
|
+
|
17
|
+
def connect
|
18
|
+
set_up_amqp_connection
|
19
|
+
set_up_api_connection
|
20
|
+
|
21
|
+
if block_given?
|
22
|
+
yield
|
23
|
+
disconnect
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def disconnect
|
28
|
+
@channel.close if @channel
|
29
|
+
@connection.close if @connection
|
30
|
+
@channel, @connection, @exchange, @api_client = nil, nil, nil, nil
|
31
|
+
end
|
32
|
+
|
33
|
+
# Connect to RabbitMQ via AMQP. This sets up the main connection and
|
34
|
+
# channel we use for talking to RabbitMQ. It also ensures the existance of
|
35
|
+
# the exchange we'll be using.
|
36
|
+
def set_up_amqp_connection
|
37
|
+
host, port, vhost = @config[:mq_host], @config[:mq_port]
|
38
|
+
username, password = @config[:mq_username], @config[:mq_password]
|
39
|
+
vhost = @config[:mq_vhost]
|
40
|
+
uri = "#{username}:#{password}@#{host}:#{port}/#{vhost.sub(/^\//, '')}"
|
41
|
+
logger.info "connecting to rabbitmq (amqp://#{uri})"
|
42
|
+
|
43
|
+
@connection = Bunny.new(host: host, port: port, vhost: vhost,
|
44
|
+
username: username, password: password,
|
45
|
+
heartbeat: 1, automatically_recover: true,
|
46
|
+
network_recovery_interval: 1)
|
47
|
+
@connection.start
|
48
|
+
|
49
|
+
logger.info 'opening rabbitmq channel'
|
50
|
+
@channel = @connection.create_channel
|
51
|
+
|
52
|
+
exchange_name = @config[:mq_exchange]
|
53
|
+
logger.info "using topic exchange '#{exchange_name}'"
|
54
|
+
@exchange = @channel.topic(exchange_name, durable: true)
|
55
|
+
rescue Bunny::TCPConnectionFailed => ex
|
56
|
+
logger.error "amqp connection error: #{ex.message.downcase}"
|
57
|
+
uri = "amqp://#{host}:#{port}"
|
58
|
+
raise ConnectionError.new("couldn't connect to rabbitmq at #{uri}")
|
59
|
+
rescue Bunny::PreconditionFailed => ex
|
60
|
+
logger.error ex.message
|
61
|
+
raise WorkerSetupError.new('could not create exchange due to a type ' +
|
62
|
+
'conflict with an existing exchange, ' +
|
63
|
+
'remove the existing exchange and try again')
|
64
|
+
end
|
65
|
+
|
66
|
+
# Set up the connection to the RabbitMQ management API. Unfortunately, this
|
67
|
+
# is necessary to do a few things that are impossible over AMQP. E.g.
|
68
|
+
# listing queues and bindings.
|
69
|
+
def set_up_api_connection
|
70
|
+
host, port = @config[:mq_api_host], @config[:mq_api_port]
|
71
|
+
username, password = @config[:mq_username], @config[:mq_password]
|
72
|
+
|
73
|
+
management_uri = "http://#{username}:#{password}@#{host}:#{port}/"
|
74
|
+
logger.info "connecting to rabbitmq management api (#{management_uri})"
|
75
|
+
|
76
|
+
@api_client = CarrotTop.new(host: host, port: port,
|
77
|
+
user: username, password: password)
|
78
|
+
@api_client.exchanges
|
79
|
+
rescue Errno::ECONNREFUSED => ex
|
80
|
+
logger.error "api connection error: #{ex.message.downcase}"
|
81
|
+
raise ConnectionError.new("couldn't connect to api at #{management_uri}")
|
82
|
+
rescue Net::HTTPServerException => ex
|
83
|
+
logger.error "api connection error: #{ex.message.downcase}"
|
84
|
+
if ex.response.code == '401'
|
85
|
+
raise AuthenticationError.new('invalid api credentials')
|
86
|
+
else
|
87
|
+
raise
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
# Create / get a durable queue.
|
92
|
+
def queue(name)
|
93
|
+
@channel.queue(name, durable: true)
|
94
|
+
end
|
95
|
+
|
96
|
+
# Return a mapping of queue names to the routing keys they're bound to.
|
97
|
+
def bindings
|
98
|
+
results = Hash.new { |hash, key| hash[key] = [] }
|
99
|
+
@api_client.bindings.each do |binding|
|
100
|
+
next if binding['destination'] == binding['routing_key']
|
101
|
+
next unless binding['source'] == @config[:mq_exchange]
|
102
|
+
next unless binding['vhost'] == @config[:mq_vhost]
|
103
|
+
results[binding['destination']] << binding['routing_key']
|
104
|
+
end
|
105
|
+
results
|
106
|
+
end
|
107
|
+
|
108
|
+
# Bind a queue to the broker's exchange on the routing keys provided. Any
|
109
|
+
# existing bindings on the queue that aren't present in the array of
|
110
|
+
# routing keys will be unbound.
|
111
|
+
def bind_queue(queue, routing_keys)
|
112
|
+
# Find the existing bindings, and unbind any redundant bindings
|
113
|
+
queue_bindings = bindings.select { |dest, keys| dest == queue.name }
|
114
|
+
queue_bindings.each do |dest, keys|
|
115
|
+
keys.reject { |key| routing_keys.include?(key) }.each do |key|
|
116
|
+
logger.debug "removing redundant binding #{queue.name} <--> #{key}"
|
117
|
+
queue.unbind(@exchange, routing_key: key)
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
# Ensure all the desired bindings are present
|
122
|
+
routing_keys.each do |routing_key|
|
123
|
+
logger.debug "creating binding #{queue.name} <--> #{routing_key}"
|
124
|
+
queue.bind(@exchange, routing_key: routing_key)
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
# Each subscriber is run in a thread. This calls Thread#join on each of the
|
129
|
+
# subscriber threads.
|
130
|
+
def wait_on_threads(timeout)
|
131
|
+
# HACK: work_pool.join doesn't allow a timeout to be passed in, so we
|
132
|
+
# use instance_variable_get to gain access to the threadpool, and
|
133
|
+
# manuall call thread.join with a timeout
|
134
|
+
threads = work_pool_threads
|
135
|
+
|
136
|
+
# Thread#join returns nil when the timeout is hit. If any return nil,
|
137
|
+
# the threads didn't all join so we return false.
|
138
|
+
per_thread_timeout = timeout.to_f / threads.length
|
139
|
+
threads.none? { |thread| thread.join(per_thread_timeout).nil? }
|
140
|
+
end
|
141
|
+
|
142
|
+
def stop
|
143
|
+
@channel.work_pool.kill
|
144
|
+
end
|
145
|
+
|
146
|
+
def ack(delivery_tag)
|
147
|
+
@channel.ack(delivery_tag, false)
|
148
|
+
end
|
149
|
+
|
150
|
+
def publish(routing_key, message)
|
151
|
+
payload = JSON.dump(message)
|
152
|
+
|
153
|
+
if @connection && @connection.open?
|
154
|
+
logger.info "publishing message '#{message.inspect}' to #{routing_key}"
|
155
|
+
@exchange.publish(payload, routing_key: routing_key, persistent: true,
|
156
|
+
timestamp: Time.now.to_i, message_id: generate_id)
|
157
|
+
else
|
158
|
+
logger.error "Unable to publish : routing key: #{routing_key}, " +
|
159
|
+
"message: #{message}"
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
private
|
164
|
+
|
165
|
+
def work_pool_threads
|
166
|
+
# TODO: fix bunny so we don't need to do this
|
167
|
+
@channel.work_pool.instance_variable_get(:@threads)
|
168
|
+
end
|
169
|
+
|
170
|
+
def generate_id
|
171
|
+
SecureRandom.uuid
|
172
|
+
end
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|