hutch 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
+