agni 0.0.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+ # Gem's dependencies are in messenger.gemspec
3
+ gemspec
@@ -0,0 +1,33 @@
1
+ Copyright (c) 2012-2013, ApartmentList
2
+ All rights reserved.
3
+
4
+ This software is made available under the terms of the three-clause
5
+ BSD license:
6
+
7
+ http://opensource.org/licenses/BSD-3-Clause
8
+
9
+ Redistribution and use in source and binary forms, with or without
10
+ modification, are permitted provided that the following conditions are
11
+ met:
12
+
13
+ Redistributions of source code must retain the above copyright
14
+ notice, this list of conditions and the following disclaimer.
15
+ Redistributions in binary form must reproduce the above copyright
16
+ notice, this list of conditions and the following disclaimer in
17
+ the documentation and/or other materials provided with the
18
+ distribution. Neither the name 'ApartmentList' nor the names of
19
+ its contributors may be used to endorse or promote products
20
+ derived from this software without specific prior written
21
+ permission.
22
+
23
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
24
+ "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
25
+ LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
26
+ A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
27
+ HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
28
+ SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
29
+ LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
30
+ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
31
+ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
32
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
33
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,120 @@
1
+ # Agni
2
+
3
+ ## Introduction
4
+
5
+ Agni is a gem that wraps around the Ruby AMQP gem that provides
6
+ a very simple API for publishing and subscribing to messages.
7
+
8
+ ### Simple
9
+ You'll need only two things to make use of Agni:
10
+ - The URL to an AMQP instance
11
+ - The name of a queue that you'll be writing to and/or reading from
12
+
13
+ That's it!
14
+
15
+ If you know a lot about AMQP, you'll notice that the use cases for
16
+ this class remove the notion of channels, consumers, exchanges,
17
+ bindings, routes, route keys and other primitives used by AMQP. This
18
+ is intentional; the goal of Agni is to provide the simplest
19
+ useful API for messaging. That API is subject to change to the extent
20
+ it stays in line with the original design goals.
21
+
22
+ ### Each message is consumed exactly once
23
+
24
+ Agni is designed for a simple, but very useful, configuration
25
+ of AMQP that is designed to allow 1:1, 1:n and m:n configurations for
26
+ message passing between Ruby VMs running across many machines.
27
+
28
+ One guiding principle of this queue is that each message will only
29
+ ever be consumed once. There are use cases where that behavior is not
30
+ desirable, and for those use cases Agni should be specialized or
31
+ another approach should be used altogether.
32
+
33
+ Another guiding principle is that messages should be durable; that is,
34
+ they should survive the restart of the AMQP infrastructure.
35
+ Similarly, messages require acknowledgment. Agni takes care of
36
+ this for you, but it means that if your receiving code throws an
37
+ exception or crashes before it gets a chance to process the exception
38
+ fully, it will be re-queued for later processing.
39
+
40
+ ### Prioritization
41
+
42
+ One of the main features of Agni is that it supports a form of
43
+ message prioritization. This allows systems that experience bursty
44
+ message traffic to remain responsive by prioritizing messages that
45
+ can't wait for every message ahead of them to be processed. See below
46
+ for examples of prioritization.
47
+
48
+ Currently, Agni supports priority levels 0 through 9 (inclusive),
49
+ with 0 being the highest priority. There's nothing inherent in
50
+ Agni's architecture that limits it to 10 priority levels, but we
51
+ haven't needed more than that, yet. =)
52
+
53
+ ### Example uses
54
+
55
+ One example use case of Agni is to deliver data between stages of a
56
+ backend application (for example, data retrieval and data processing).
57
+ Each Ruby VM that wants to participate can instantiate an Agni
58
+ messenger with the same AMQP URL and all publish/subscribe to the same
59
+ queue name.
60
+
61
+ Agni could also conceivably be used to push messages onto queues
62
+ that represent units of work that need to be performed, allowing for
63
+ an arbitrary number of requesters and workers to broker work via a
64
+ single queue.
65
+
66
+ Another use case would utilize Agni to create a scalable,
67
+ asynchronous messaging architecture, allowing many components spanning
68
+ a dozen services to communicate exclusively via various Agni
69
+ queues to schedule work, report status, update data, and collect
70
+ metrics. Agni should, even in its current humble state, support
71
+ such a use case.
72
+
73
+ ## Installation
74
+
75
+ Add this line to your application's Gemfile:
76
+
77
+ gem 'messenger'
78
+
79
+ And then execute:
80
+
81
+ $ bundle
82
+
83
+ Or install it yourself as:
84
+
85
+ $ gem install messenger
86
+
87
+ ## Usage
88
+
89
+ If you're running an AMQP instance locally for testing, try the following.
90
+
91
+ Set up a subscriber:
92
+
93
+ require 'messenger'
94
+ m = Agni::Messenger.new('amqp://localhost')
95
+ m.subscribe('test_queue') {|m,p| printf p}
96
+
97
+ Set up a publisher (in another Ruby instance, on another machine, etc.):
98
+
99
+ require 'messenger'
100
+ m = Agni::Messenger.new('amqp://localhost')
101
+
102
+ It's easy to send some messages. If you don't specify a priority,
103
+ they will default to a priority of 4.
104
+
105
+ 1.upto(100).each{|n| m.publish("test#{n}", 'test_queue')}
106
+
107
+ It's equally easy to specify a priority. This will add 100,000
108
+ messages evenly across all 10 priorities. If you have e.g. a RabbitMQ
109
+ dashboard handy, you can watch as the queues get emptied in priority
110
+ order.
111
+
112
+ 1.upto(100000).each{|n| m.publish("test#{n}", 'test_queue', n%10)}
113
+
114
+ ## Contributing
115
+
116
+ 1. Fork it
117
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
118
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
119
+ 4. Push to the branch (`git push origin my-new-feature`)
120
+ 5. Create new Pull Request
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
@@ -0,0 +1,29 @@
1
+ # -*- mode: ruby; encoding: utf-8 -*-
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'agni/version'
5
+
6
+ Gem::Specification.new do |gem|
7
+ gem.name = "agni"
8
+ gem.version = Agni::VERSION
9
+ gem.authors = ["Rick Dillon"]
10
+ gem.email = ["rpdillon@apartmentlist.com"]
11
+ gem.description = %q{A wrapper around the AMQP gem to support easy messaging between applications.}
12
+ gem.summary = %q{Easy RabbitMQ Messaging}
13
+ gem.license = '3-Clause BSD'
14
+ gem.homepage = "https://github.com/apartmentlist/agni"
15
+
16
+ gem.files = `git ls-files`.split($/)
17
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
18
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
19
+ gem.require_paths = ["lib"]
20
+
21
+ gem.add_dependency('rake')
22
+ gem.add_dependency('amqp')
23
+ gem.add_dependency('algorithms')
24
+ gem.add_dependency('log_mixin')
25
+
26
+ gem.add_development_dependency('rspec')
27
+ gem.add_development_dependency('pry')
28
+ gem.add_development_dependency('mocha')
29
+ end
@@ -0,0 +1,29 @@
1
+ require 'log_mixin'
2
+ require 'amqp'
3
+ require 'algorithms'
4
+ require 'agni/version'
5
+ require 'agni/queue'
6
+ require 'agni/messenger'
7
+ require 'agni/agni_error'
8
+
9
+ module Agni
10
+ # Enforce durability-by-default at the queue and message level
11
+ DEFAULT_QUEUE_OPTS = {durable: true}.freeze
12
+ DEFAULT_MESSAGE_OPTS = {persistent: true}.freeze
13
+ DEFAULT_CHANNEL_OPTS = {
14
+ auto_recovery: true,
15
+ }.freeze
16
+ DEFAULT_CONNECTION_OPTS = {
17
+ auto_recovery: true,
18
+ # Log cases in which we're unexpectedly disconnected from the server
19
+ on_tcp_connection_failure: ->{ warning("TCP connection failure detected") }
20
+ }
21
+ # Each logical queue has 10 AMQP queues backing it to support
22
+ # prioritization. Those priorities are numbered 0 (highest)
23
+ # through 9 (lowest).
24
+ PRIORITY_LEVELS = (0..9).to_a.freeze
25
+ # To allow room above and below the default, we put it in the middle
26
+ DEFAULT_PRIORITY = 4
27
+ DEFAULT_PREFETCH = 50
28
+ DEFAULT_THREADPOOL_SIZE = 5
29
+ end
@@ -0,0 +1,9 @@
1
+ module Agni
2
+
3
+ # Error class used to denote error conditions specific to the
4
+ # agni infrastructure, and not those due to, for example,
5
+ # missing arguments.
6
+ class AgniError < StandardError
7
+ end
8
+
9
+ end
@@ -0,0 +1,191 @@
1
+ module Agni
2
+ class Messenger
3
+ include LogMixin
4
+
5
+ attr_reader :connection
6
+
7
+ # Creates a Messenger, connecting to the supplied URL.
8
+ #
9
+ # @param amqp_url [String] the url to the AMQP instance this
10
+ # Messenger should connect to. Should begin with 'amqp://'.
11
+ def initialize(amqp_url)
12
+ if amqp_url.nil? || amqp_url.empty?
13
+ raise ArgumentError, "AMQP url is required to create a Messenger"
14
+ end
15
+ self.configure_logs
16
+ # Start EventMachine if needed
17
+ unless EventMachine.reactor_running?
18
+ @em_thread = Thread.new { EventMachine.run }
19
+ end
20
+
21
+ # Block until EventMachine has started
22
+ info("Waiting for EventMachine to start")
23
+ spin_until { EventMachine.reactor_running? }
24
+ info("EventMachine start detected")
25
+
26
+ EventMachine.threadpool_size = ENV.fetch('EM_THREADPOOL_SIZE', DEFAULT_THREADPOOL_SIZE).to_i
27
+
28
+ unless @connection = AMQP.connect(amqp_url, DEFAULT_CONNECTION_OPTS)
29
+ raise AgniError, "Unable to connect to AMQP instance at #{amqp_url}"
30
+ end
31
+
32
+ # A hash which maps queue names to Agni::Queue objects.
33
+ # Tracks what queues we have access to.
34
+ @queues = {}
35
+ end
36
+
37
+ # Gets a queue with the given options. If no options are
38
+ # provided, a default set of options will be used that makes the
39
+ # queue save its messages to disk so that they won't be lost if
40
+ # the AMQP service is restarted.
41
+ #
42
+ # @return [Agni::Queue] the queue with the provided name
43
+ # @raise ArgumentError if the queue name is not provided
44
+ # @raise AgniError if the queue has already been created with
45
+ # an incompatible set of options.
46
+ def get_queue(queue_name, options={})
47
+ @queues.fetch(queue_name) do |queue_name|
48
+ queue = Queue.new(queue_name, self, options)
49
+ @queues[queue_name] = queue
50
+ end
51
+ end
52
+
53
+ # @return [TrueClass, FalseClass] whether or not a queue with the
54
+ # given name is known to this Messenger instance.
55
+ def queue?(queue_name)
56
+ @queues.key?(queue_name)
57
+ end
58
+
59
+ # Get and return the number of messages in a given queue.
60
+ #
61
+ # @param queue_name [String] the name of the queue for which the
62
+ # the message count should be fetched.
63
+ # @return [Fixnum] the number of messages in the queue with
64
+ # provided queue name. If the queue is not yet created, the
65
+ # method will return +nil+.
66
+ # @raise ArgumentError if the queue_name is not supplied
67
+ def queue_messages(queue_name)
68
+ get_queue(queue_name).message_count if queue?(queue_name)
69
+ end
70
+
71
+ # @param queue_name [String] the name of the queue for which the
72
+ # the consumer count should be fetched.
73
+ # @return [Fixnum] the number of consumers for the queue with
74
+ # provided queue name. If the queue is not yet created, it will
75
+ # be created when this method is called.
76
+ # @raise ArgumentError if the queue_name is not supplied
77
+ def queue_consumers(queue_name)
78
+ get_queue(queue_name).consumer_count if queue?(queue_name)
79
+ end
80
+
81
+ # Convenience method that publishes a message to the given queue
82
+ # name.
83
+ #
84
+ # One of the main uses of the options hash is to specify a message
85
+ # priority between 0 and 9:
86
+ #
87
+ # messenger.publish("Hello World", "test_queue", priority: 7)
88
+ #
89
+ # But the default priority is 4, so this would be published with a
90
+ # priority of 4:
91
+ #
92
+ # messenger.publish("Hello World", "test_queue")
93
+ #
94
+ # @param msg [String] the message to enqueue
95
+ # @param queue_name [String] the name of the queue to publish to
96
+ # @param options [Hash] optional -- options that will be passed to
97
+ # the underlying AMQP queue during publishing. All keys should
98
+ # be symbols.
99
+ # @option :priority [FixNum] the priority of the message
100
+ # 0(high) - 9(low)
101
+ def publish(msg, queue_name, options={})
102
+ priority = options.delete(:priority) || DEFAULT_PRIORITY
103
+ get_queue(queue_name).publish(msg, priority, options)
104
+ end
105
+
106
+ # @note The block passed to this method must not block, since it
107
+ # will be run in a single-threaded context.
108
+ #
109
+ # Convenience method that takes a queue name (creating the queue
110
+ # if necessary) and accepts a block that it will yield to for each
111
+ # incoming message. The block passed in to this method should
112
+ # accept two arguments: the metadata of the message being
113
+ # received, as well as the payload of the message.
114
+ #
115
+ # This method is non-blocking, and if at any time the Messenger
116
+ # should no longer yield to the provided block when new messages
117
+ # arrive, the +unsubscribe+ method can be called on the Messenger
118
+ # and given the queue name to unsubscribe from.
119
+ #
120
+ # If no block is passed to this method, it will simply subscribe
121
+ # to the queue and drain it of messages as they come in.
122
+ #
123
+ # To prevent lossage, this method will set up the subscription
124
+ # with the AMQP server to require acking of the messages by the
125
+ # client. As far as the end user is concerned, this means that if
126
+ # the messenger dies an untimely death, any unprocessed messages
127
+ # that remained in the buffer will be requeued on the server.
128
+ # Messenger will take care of the acking for the user, unless an
129
+ # option is passed to indicate that the user will handle acking in
130
+ # the provided block.
131
+ #
132
+ # @param queue_name [String] The name of the queue that should be
133
+ # examined for messages
134
+ # @param options [Hash] (optional) A hash of options
135
+ # @option options [TrueClass, FalseClass] :ack Whether messenger
136
+ # should ack incoming messages for this subscription. If
137
+ # set to +false+, the block passed to this method must ack
138
+ # messages when they have been processed. Defaults to
139
+ # +true+.
140
+ # @yield handler [metadata, payload] a block that handles the incoming message
141
+ # @return [Agni::Queue] the queue that has been subscribed
142
+ def subscribe(queue_name, options={}, &handler)
143
+ if queue_name.nil? || queue_name.empty?
144
+ raise ArgumentError, 'Queue name must be present when subscribing'
145
+ end
146
+ queue = get_queue(queue_name)
147
+ if queue.subscribed?
148
+ raise AgniError, "Queue #{queue_name} is already subscribed!"
149
+ end
150
+ queue.subscribe(handler, options)
151
+ # spin_until { queue.subscribed? }
152
+ end
153
+
154
+ # Unsubscribe this messenger from the queue associated with the
155
+ # given name.
156
+ #
157
+ # @raise ArgumentError if the queue name is empty
158
+ # @raise AgniError if the queue does not exist
159
+ def unsubscribe(queue_name)
160
+ if queue_name.nil? || queue_name.empty?
161
+ raise ArgumentError, 'Queue name must be present when unsubscribing'
162
+ end
163
+ if queue = get_queue(queue_name)
164
+ queue.unsubscribe
165
+ end
166
+ end
167
+
168
+ # This method allows a client of the messenger to block on the
169
+ # execution of the EventMachine, so it can run in a context that
170
+ # is dedicated to running for the purpose of receiving messages.
171
+ def wait
172
+ @em_thread.join
173
+ end
174
+
175
+ # Returns +true+ if the messenger is connected to AMQP, +false+
176
+ # otherwise.
177
+ def connected?
178
+ return @connection.connected?
179
+ end
180
+
181
+ private
182
+
183
+ def spin_until
184
+ while not yield
185
+ sleep(0.1)
186
+ end
187
+ end
188
+
189
+ end
190
+
191
+ end
@@ -0,0 +1,152 @@
1
+ module Agni
2
+ class Queue
3
+ include LogMixin
4
+
5
+ # Core method responsible for catching queue name problems, like
6
+ # nil values and empty strings.
7
+ #
8
+ # @param queue_name [String] the name of this queue
9
+ # @param messenger [Agni::Messenger] the messenger object
10
+ # with which this queue is associated
11
+ # @param options [Hash] options that will be passed to the AMQP
12
+ # gem during queue creation
13
+ def initialize(queue_name, messenger, options={})
14
+ if queue_name.nil? || queue_name.empty?
15
+ raise ArgumentError, 'Queue name must be present when creating a queue'
16
+ end
17
+ self.configure_logs
18
+ @logical_queue_name = queue_name
19
+
20
+ begin
21
+ @queues = PRIORITY_LEVELS.map do |priority|
22
+ create_queue(messenger, priority, options)
23
+ end
24
+ rescue AMQP::IncompatibleOptionsError
25
+ raise AgniError,
26
+ "One of the queues needed to create #{@logical_queue_name} " +
27
+ "has already been created with different options!"
28
+ end
29
+
30
+ # The in-memory queue we use to prioritize incoming messages of
31
+ # different priorities
32
+ @queue_mutex = Mutex.new
33
+ @memory_queue = Containers::MinHeap.new
34
+ end
35
+
36
+ # Subscribes to this queue, handling each incoming message with
37
+ # the provided +handler+.
38
+ # @param handler [Proc] accepts two arguments:
39
+ # metadata [Hash] a hash of attributes as it is provided by the
40
+ # underlying AMQP implementation.
41
+ # payload [String] the message itself, as was provided by the
42
+ # publisher
43
+ # The return value from the handler will be discarded.
44
+ def subscribe(handler, options={})
45
+ if subscribed?
46
+ raise AgniError, 'Queue #{queue_name} is already subscribed'
47
+ end
48
+ ack = options[:ack].nil? ? true : options[:ack]
49
+ handle_func = lambda do
50
+ metadata, payload = pop
51
+ handler[metadata, payload] if handler
52
+ EventMachine.next_tick{ metadata.ack } if ack
53
+ end
54
+ @queues.each do |q|
55
+ queue = q[:queue]
56
+ priority = q[:priority]
57
+ queue.subscribe(:ack => true) do |metadata, payload|
58
+ @queue_mutex.synchronize do
59
+ @memory_queue.push(priority, [metadata, payload])
60
+ end
61
+ EventMachine.next_tick { EventMachine.defer(handle_func) }
62
+ end
63
+ end
64
+ self
65
+ end
66
+
67
+ def unsubscribe
68
+ unless subscribed?
69
+ raise AgniError, 'Queue #{queue_name} is not subscribed'
70
+ end
71
+ @queues.each do |q|
72
+ q[:queue].unsubscribe
73
+ end
74
+ end
75
+
76
+ # @return [True] iff every AMQP queue is +subscribed+?
77
+ def subscribed?
78
+ @queues.map{|q| q[:queue].default_consumer}.all?{|c| c.subscribed? if c}
79
+ end
80
+
81
+ # Publishes a payload to this queue.
82
+ #
83
+ # @param payload [String] the payload of the message to publish
84
+ # @param priority [FixNum] must be one between 0 and 9, inclusive.
85
+ # @param options [Hash]
86
+ def publish(payload, priority=DEFAULT_PRIORITY, options={})
87
+ unless PRIORITY_LEVELS.include? priority
88
+ raise ArgumentError, "Invalid priority #{priority}, must be between 0 and 9"
89
+ end
90
+ queue_name = create_queue_name(@logical_queue_name, priority)
91
+ @queues[priority][:exchange].publish(payload, DEFAULT_MESSAGE_OPTS.merge(options).
92
+ merge(:routing_key => queue_name))
93
+ end
94
+
95
+ # Given a base name and a priority, creates a queue name suitable
96
+ # for use in naming an underlying AMQP queue.
97
+ #
98
+ # @param base_name [String] the base name of the queue. This is
99
+ # typcially just the queue name used when creating this
100
+ # +Agni::Queue+ object.
101
+ # @param priority [String] valid priorities are in the range 0
102
+ # through 9 inclusive.
103
+ def create_queue_name(base_name, priority)
104
+ "#{base_name}.#{priority}"
105
+ end
106
+
107
+ private
108
+
109
+ # Internal use utility method to create queue hashes. No checking
110
+ # is performed to ensure that the queue does not already exist,
111
+ # for example. Its only use right now is during initialization of
112
+ # the Agni::Queue class.
113
+ def create_queue(messenger, priority, options)
114
+ name = create_queue_name(@logical_queue_name, priority)
115
+ unless channel = AMQP::Channel.new(messenger.connection,
116
+ DEFAULT_CHANNEL_OPTS.
117
+ merge({prefetch: DEFAULT_PREFETCH}))
118
+ raise AgniError,
119
+ "Unable to obtain a channel from AMQP instance at #{amqp_url}"
120
+ end
121
+ # Get a handle to the default exchange. The default exchange
122
+ # automatically binds messages with a given routing key to a
123
+ # queue with the same name, eliminating the need to create
124
+ # specific direct bindings for each queue.
125
+ queue = channel.queue(name, DEFAULT_QUEUE_OPTS.
126
+ merge(options))
127
+
128
+ exchange = channel.default_exchange
129
+ # Each 'queue' in the queue array is a hash. Here's how each
130
+ # hash is laid out:
131
+ {
132
+ priority: priority,
133
+ name: name,
134
+ channel: channel,
135
+ queue: queue,
136
+ exchange: exchange
137
+ }
138
+ end
139
+
140
+ # Removes and returns an item from the priority queue in a
141
+ # thread-safe manner. Thread safety reasoning: all calls to
142
+ # shared queue are locked by a single mutex.
143
+ def pop
144
+ val = []
145
+ @queue_mutex.synchronize do
146
+ val = @memory_queue.pop
147
+ end
148
+ val
149
+ end
150
+
151
+ end
152
+ end
@@ -0,0 +1,3 @@
1
+ module Agni
2
+ VERSION = "0.0.3"
3
+ end
@@ -0,0 +1,142 @@
1
+ require 'agni'
2
+ require 'spec_helper'
3
+
4
+ describe Agni::Messenger do
5
+ let (:amqp_url) { "amqp://localhost" }
6
+ # An Agni object using mocked AMQP methods
7
+ let (:connection) { mock('connection') }
8
+ let (:channel) { mock('channel') }
9
+ let (:exchange) { mock('exchange') }
10
+ let (:messenger) {
11
+ EventMachine.stubs(:reactor_running?).returns(true)
12
+ AMQP.expects(:connect).with(amqp_url, is_a(Hash)).returns(connection)
13
+ Agni::Messenger.new(amqp_url)
14
+ }
15
+
16
+ describe 'construction' do
17
+ it 'should create a connection, channel and exchange on instantiation' do
18
+ messenger.class.should == Agni::Messenger
19
+ end
20
+
21
+ it 'should throw an exception given a blank url' do
22
+ lambda{Agni::Messenger.new('')}.should raise_error(ArgumentError)
23
+ end
24
+
25
+ it 'should set the EventMachine threadpool size from the environment' do
26
+ ENV['EM_THREADPOOL_SIZE'] = '13'
27
+ EventMachine.expects(:threadpool_size=).with(13)
28
+ m = messenger
29
+ ENV.delete('EM_THREADPOOL_SIZE')
30
+ end
31
+
32
+ it 'should use a default threadpool size if the env var is not set' do
33
+ ENV['EM_THREADPOOL_SIZE'].should == nil
34
+ EventMachine.expects(:threadpool_size=).with(Agni::DEFAULT_THREADPOOL_SIZE)
35
+ m = messenger
36
+ end
37
+ end
38
+
39
+ describe 'get_queue' do
40
+
41
+ it 'should raise an error if the queue name is blank' do
42
+ lambda{ messenger.get_queue('') }.should raise_error(ArgumentError)
43
+ end
44
+
45
+ it 'should create the queue on the channel' do
46
+ queue_name = 'test_queue'
47
+ Agni::Queue.stubs(:new).with(queue_name,
48
+ is_a(Agni::Messenger),
49
+ {})
50
+ messenger.get_queue(queue_name)
51
+ end
52
+
53
+ it 'should not create the queue if it exists' do
54
+ queue_name = "test_queue"
55
+ queues = messenger.instance_variable_get(:@queues)
56
+ queues[queue_name] = mock
57
+ Agni::Queue.expects(:new).never
58
+ messenger.get_queue(queue_name)
59
+ end
60
+
61
+ it "should create the queue if it doesn't exist" do
62
+ queue_name = 'test_queue'
63
+ Agni::Queue.expects(:new).with(queue_name,
64
+ is_a(Agni::Messenger),
65
+ {})
66
+ messenger.get_queue(queue_name)
67
+ end
68
+
69
+ end
70
+
71
+ describe 'publish' do
72
+ let (:queue_name) { "test_queue" }
73
+ let (:message) { "test message" }
74
+
75
+ it 'should raise an error when attempting to publish to a nameless queue' do
76
+ lambda {messenger.publish(message, '')}.should raise_error(ArgumentError)
77
+ end
78
+
79
+ context 'with good data' do
80
+
81
+ it 'should create a queue and publish to it' do
82
+ queue_name = 'test_queue'
83
+ queue = mock('queue')
84
+ queue.expects(:publish).with(message, Agni::DEFAULT_PRIORITY, {})
85
+ messenger.expects(:get_queue).with(queue_name).returns(queue)
86
+ messenger.publish(message, queue_name)
87
+ end
88
+
89
+ it 'should pass custom headers to queue object' do
90
+ test_headers = {:headers => {:operation => "TEST_OPERATION"}}
91
+ queue = mock('queue')
92
+ queue.expects(:publish).with(message, Agni::DEFAULT_PRIORITY, test_headers)
93
+ messenger.expects(:get_queue).with(queue_name).returns(queue)
94
+ messenger.publish(message, queue_name, options=test_headers)
95
+ end
96
+
97
+ end
98
+ end
99
+
100
+ describe 'subscribe and unsubscribe' do
101
+ it 'should raise an error if attempting to subscribe to a nameless queue' do
102
+ lambda{messenger.subscribe('')}.should raise_error(ArgumentError)
103
+ end
104
+
105
+ it 'should raise an error if attempting to subscribe to a nil queue' do
106
+ lambda{messenger.subscribe}.should raise_error(ArgumentError)
107
+ end
108
+
109
+ it 'should raise an error if attempting to unsubscribe from a nameless queue' do
110
+ lambda{messenger.unsubscribe('')}.should raise_error(ArgumentError)
111
+ end
112
+
113
+ it 'should raise an error if attempting to unsubscribe from a nil queue' do
114
+ lambda{messenger.unsubscribe}.should raise_error(ArgumentError)
115
+ end
116
+
117
+ context 'with good data' do
118
+ let (:queue_name) { 'test_queue' }
119
+ let (:queue) { mock('queue') }
120
+
121
+ it 'should should subscribe to the queue associated with the queue name provided' do
122
+ queue.expects(:subscribed?).returns(false)
123
+ queue.expects(:subscribe).with(is_a(Proc), is_a(Hash))
124
+ messenger.expects(:get_queue).with(queue_name).returns(queue)
125
+ messenger.subscribe(queue_name) { |m,p| puts 'ohai'}
126
+ end
127
+
128
+ it 'should unsubscribe from a subscribed queue' do
129
+ queue.expects(:unsubscribe)
130
+ messenger.expects(:get_queue).with(queue_name).returns(queue)
131
+ messenger.unsubscribe(queue_name)
132
+ end
133
+
134
+ it 'should not attempt to unsubscribe from a queue that does not exist' do
135
+ messenger.expects(:get_queue).with(queue_name).returns(nil)
136
+ messenger.unsubscribe(queue_name)
137
+ end
138
+
139
+ end
140
+ end
141
+
142
+ end
@@ -0,0 +1,116 @@
1
+ require 'agni'
2
+ require 'pry'
3
+ require 'spec_helper'
4
+
5
+ describe Agni::Queue do
6
+
7
+ let (:channel) { mock('channel') }
8
+ let (:connection) { mock('connection') }
9
+ let (:messenger) do
10
+ mock('messenger').tap do |m|
11
+ m.stubs(:channel).returns(channel)
12
+ m.stubs(:connection).returns(connection)
13
+ end
14
+ end
15
+ let (:channel) { mock('channel') }
16
+ let (:exchange) { mock('exchange') }
17
+ let (:amqp_queue) { mock('amqp_queue') }
18
+ let (:queue_name) { 'test_queue' }
19
+ let (:queue) do
20
+ AMQP::Channel.stubs(:new).returns(channel)
21
+ channel.stubs(:default_exchange).returns(exchange)
22
+ channel.expects(:queue).times(Agni::PRIORITY_LEVELS.size)
23
+ Agni::Queue.new(queue_name, messenger)
24
+ end
25
+
26
+ describe :initialize do
27
+ it 'should raise an exeception if a the queue name is nil' do
28
+ lambda { Agni::Queue.new(nil, messenger) }.should raise_exception(ArgumentError)
29
+ end
30
+
31
+ it 'should construct an AMQP queue for each priority level' do
32
+ queue
33
+ end
34
+ end
35
+
36
+ describe :subscribe do
37
+ it 'should raise an exception if the queue is already subscribed' do
38
+ queue.expects(:subscribed?).returns(true)
39
+ lambda { queue.subscribe(nil) }.should raise_exception(Agni::AgniError)
40
+ end
41
+
42
+ it 'should invoke subscribe for each queue' do
43
+ queue.expects(:subscribed?).returns(false)
44
+ mock_queues = (0..9).map do |n|
45
+ {:queue =>
46
+ mock("mockqueue-#{n}").tap do |q|
47
+ q.expects(:subscribe).with(is_a(Hash))
48
+ end,
49
+ :priority => n
50
+ }
51
+ end
52
+ queue.instance_variable_set(:@queues, mock_queues)
53
+ test_handler = lambda {}
54
+ queue.subscribe(test_handler)
55
+ end
56
+ end
57
+
58
+ describe :unsubscribe do
59
+ it 'should raise an exception if the queue is not subscribed' do
60
+ queue.expects(:subscribed?).returns(false)
61
+ lambda { queue.unsubscribe }.should raise_exception(Agni::AgniError)
62
+ end
63
+ end
64
+
65
+ describe :subscribed? do
66
+ it 'should return true if all consumers are subscribed' do
67
+ mock_queues = (0..9).map do |n|
68
+ {:queue =>
69
+ mock("mockqueue-#{n}").tap do |q|
70
+ consumer = mock("mockconsumer-#{n}").tap do |c|
71
+ c.expects(:subscribed?).returns(true)
72
+ end
73
+ q.expects(:default_consumer).returns(consumer)
74
+ end
75
+ }
76
+ end
77
+ queue.instance_variable_set(:@queues, mock_queues)
78
+ queue.subscribed?.should be_true
79
+ end
80
+
81
+ it 'should return false if one consumer is not subscribed' do
82
+ mock_queues = (0..9).map do |n|
83
+ {:queue =>
84
+ mock("mockqueue-#{n}").tap do |q|
85
+ consumer = mock("mockconsumer-#{n}").tap do |c|
86
+ c.expects(:subscribed?).returns(n == 9 ? false : true)
87
+ end
88
+ q.expects(:default_consumer).returns(consumer)
89
+ end
90
+ }
91
+ end
92
+ queue.instance_variable_set(:@queues, mock_queues)
93
+ queue.subscribed?.should be_false
94
+ end
95
+ end
96
+
97
+ describe :publish do
98
+ it 'should raise and exception if the priority is out of range' do
99
+ lambda { queue.publish('test', 11)}.should raise_exception(ArgumentError)
100
+ end
101
+
102
+ it 'should publish only to the proper priority level' do
103
+ mock_queues = (0..9).map do |n|
104
+ {:exchange => mock("mockexchange-#{n}")}
105
+ end
106
+ test_priority = 7
107
+ mock_queues[test_priority][:exchange].expects(:publish).with("Hello", is_a(Hash))
108
+ ((0..9).to_a - [test_priority]).each do |n|
109
+ mock_queues[n][:exchange].expects(:publish).never
110
+ end
111
+ queue.instance_variable_set(:@queues, mock_queues)
112
+ queue.publish("Hello", test_priority)
113
+ end
114
+ end
115
+
116
+ end
@@ -0,0 +1,3 @@
1
+ RSpec.configure do |config|
2
+ config.mock_with :mocha
3
+ end
metadata ADDED
@@ -0,0 +1,140 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: agni
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.3
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Rick Dillon
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2013-05-24 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: rake
16
+ requirement: &5495860 !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: '0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: *5495860
25
+ - !ruby/object:Gem::Dependency
26
+ name: amqp
27
+ requirement: &5495260 !ruby/object:Gem::Requirement
28
+ none: false
29
+ requirements:
30
+ - - ! '>='
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: *5495260
36
+ - !ruby/object:Gem::Dependency
37
+ name: algorithms
38
+ requirement: &5510580 !ruby/object:Gem::Requirement
39
+ none: false
40
+ requirements:
41
+ - - ! '>='
42
+ - !ruby/object:Gem::Version
43
+ version: '0'
44
+ type: :runtime
45
+ prerelease: false
46
+ version_requirements: *5510580
47
+ - !ruby/object:Gem::Dependency
48
+ name: log_mixin
49
+ requirement: &5509260 !ruby/object:Gem::Requirement
50
+ none: false
51
+ requirements:
52
+ - - ! '>='
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ type: :runtime
56
+ prerelease: false
57
+ version_requirements: *5509260
58
+ - !ruby/object:Gem::Dependency
59
+ name: rspec
60
+ requirement: &5507900 !ruby/object:Gem::Requirement
61
+ none: false
62
+ requirements:
63
+ - - ! '>='
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ type: :development
67
+ prerelease: false
68
+ version_requirements: *5507900
69
+ - !ruby/object:Gem::Dependency
70
+ name: pry
71
+ requirement: &5506460 !ruby/object:Gem::Requirement
72
+ none: false
73
+ requirements:
74
+ - - ! '>='
75
+ - !ruby/object:Gem::Version
76
+ version: '0'
77
+ type: :development
78
+ prerelease: false
79
+ version_requirements: *5506460
80
+ - !ruby/object:Gem::Dependency
81
+ name: mocha
82
+ requirement: &5505340 !ruby/object:Gem::Requirement
83
+ none: false
84
+ requirements:
85
+ - - ! '>='
86
+ - !ruby/object:Gem::Version
87
+ version: '0'
88
+ type: :development
89
+ prerelease: false
90
+ version_requirements: *5505340
91
+ description: A wrapper around the AMQP gem to support easy messaging between applications.
92
+ email:
93
+ - rpdillon@apartmentlist.com
94
+ executables: []
95
+ extensions: []
96
+ extra_rdoc_files: []
97
+ files:
98
+ - .gitignore
99
+ - Gemfile
100
+ - LICENSE.txt
101
+ - README.md
102
+ - Rakefile
103
+ - agni.gemspec
104
+ - lib/agni.rb
105
+ - lib/agni/agni_error.rb
106
+ - lib/agni/messenger.rb
107
+ - lib/agni/queue.rb
108
+ - lib/agni/version.rb
109
+ - spec/lib/messenger_spec.rb
110
+ - spec/lib/queue_spec.rb
111
+ - spec/spec_helper.rb
112
+ homepage: https://github.com/apartmentlist/agni
113
+ licenses:
114
+ - 3-Clause BSD
115
+ post_install_message:
116
+ rdoc_options: []
117
+ require_paths:
118
+ - lib
119
+ required_ruby_version: !ruby/object:Gem::Requirement
120
+ none: false
121
+ requirements:
122
+ - - ! '>='
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ required_rubygems_version: !ruby/object:Gem::Requirement
126
+ none: false
127
+ requirements:
128
+ - - ! '>='
129
+ - !ruby/object:Gem::Version
130
+ version: '0'
131
+ requirements: []
132
+ rubyforge_project:
133
+ rubygems_version: 1.8.11
134
+ signing_key:
135
+ specification_version: 3
136
+ summary: Easy RabbitMQ Messaging
137
+ test_files:
138
+ - spec/lib/messenger_spec.rb
139
+ - spec/lib/queue_spec.rb
140
+ - spec/spec_helper.rb