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.
- data/.gitignore +17 -0
- data/Gemfile +3 -0
- data/LICENSE.txt +33 -0
- data/README.md +120 -0
- data/Rakefile +1 -0
- data/agni.gemspec +29 -0
- data/lib/agni.rb +29 -0
- data/lib/agni/agni_error.rb +9 -0
- data/lib/agni/messenger.rb +191 -0
- data/lib/agni/queue.rb +152 -0
- data/lib/agni/version.rb +3 -0
- data/spec/lib/messenger_spec.rb +142 -0
- data/spec/lib/queue_spec.rb +116 -0
- data/spec/spec_helper.rb +3 -0
- metadata +140 -0
data/.gitignore
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -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.
|
data/README.md
ADDED
@@ -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
|
data/Rakefile
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require "bundler/gem_tasks"
|
data/agni.gemspec
ADDED
@@ -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
|
data/lib/agni.rb
ADDED
@@ -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,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
|
data/lib/agni/queue.rb
ADDED
@@ -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
|
data/lib/agni/version.rb
ADDED
@@ -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
|
data/spec/spec_helper.rb
ADDED
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
|