agni 0.0.3

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