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
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
|
+

|
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
|
+
|