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,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 41727b6727cb22516abf3d78004b4b079d7f4227
4
+ data.tar.gz: 7d6d4f335003d299b94c33c4bf297c8912932cbb
5
+ SHA512:
6
+ metadata.gz: c7e22f63f21e5afbd94f503b4933b0f43522625e592fb1f2be357ca2728c7bba378db1761bf44690c5c0cf318fb898d6cfaae1eeb7870c964638fc73a78c3b00
7
+ data.tar.gz: 007464c716f89732b2ca924223e90d92db730f5ebb23b740efb8acbc9b7a00e4285d41f4fb50af4435078fd855d77ee0fd820ccce328294556887ce608759fe7
@@ -0,0 +1,2 @@
1
+ .bundle
2
+ hutch-*.gem
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
@@ -0,0 +1,5 @@
1
+ guard 'rspec', :version => 2, :cli => '--color --format doc' do
2
+ watch(%r{^spec/.+_spec\.rb$})
3
+ watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" }
4
+ watch('spec/spec_helper.rb') { "spec" }
5
+ end
@@ -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
+
@@ -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
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative '../lib/hutch'
4
+ require_relative '../lib/hutch/cli'
5
+
6
+ cli = Hutch::CLI.new
7
+ cli.run
8
+
@@ -0,0 +1,3 @@
1
+ machine:
2
+ services:
3
+ - rabbitmq-server
@@ -0,0 +1,13 @@
1
+ require 'hutch'
2
+
3
+ class TestConsumer
4
+ include Hutch::Consumer
5
+ consume 'hutch.test'
6
+
7
+ def process(message)
8
+ puts "TestConsumer got a message: #{message}"
9
+ puts "Processing..."
10
+ sleep(1)
11
+ puts "Done"
12
+ end
13
+ end
@@ -0,0 +1,10 @@
1
+ require 'hutch'
2
+
3
+ Hutch.connect
4
+ loop do
5
+ print "Press return to send test message..."
6
+ gets
7
+ Hutch.publish('hutch.test', subject: 'test message')
8
+ puts "Send message with routing key 'hutch.test'"
9
+ end
10
+
@@ -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
@@ -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
+
@@ -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
+