cottontail 0.1.5 → 2.0.0.pre.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +9 -4
- data/.rubocop.yml +4 -0
- data/.travis.yml +15 -0
- data/Gemfile +12 -1
- data/LICENSE.txt +1 -1
- data/README.md +17 -17
- data/Rakefile +1 -1
- data/cottontail.gemspec +4 -1
- data/lib/cottontail.rb +6 -294
- data/lib/cottontail/configurable.rb +68 -0
- data/lib/cottontail/consumer.rb +215 -0
- data/lib/cottontail/consumer/collection.rb +28 -0
- data/lib/cottontail/consumer/entity.rb +45 -0
- data/lib/cottontail/consumer/launcher.rb +32 -0
- data/lib/cottontail/consumer/session.rb +27 -0
- data/lib/cottontail/version.rb +2 -2
- data/spec/cottontail/configurable_spec.rb +41 -0
- data/spec/cottontail/consumer/collection_spec.rb +95 -0
- data/spec/cottontail/consumer/entity_spec.rb +120 -0
- data/spec/integration/consumer_simple_spec.rb +41 -0
- data/spec/spec_helper.rb +42 -0
- data/spec/support/coverage.rb +16 -0
- data/spec/support/rabbitmq.rb +14 -0
- data/spec/support/test_consumer.rb +32 -0
- metadata +114 -55
@@ -0,0 +1,68 @@
|
|
1
|
+
require 'active_support/core_ext/module/delegation'
|
2
|
+
require 'active_support/concern'
|
3
|
+
|
4
|
+
module Cottontail #:nodoc:
|
5
|
+
module Configurable #:nodoc:
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
module ClassMethods #:nodoc:
|
9
|
+
delegate :set, :get, to: :config
|
10
|
+
|
11
|
+
def config
|
12
|
+
return @__config__ if defined?(@__config__)
|
13
|
+
|
14
|
+
@__config__ =
|
15
|
+
if respond_to?(:superclass) && superclass.respond_to?(:config)
|
16
|
+
superclass.config.inheritable_copy
|
17
|
+
else
|
18
|
+
# create a new "anonymous" class that will host the compiled
|
19
|
+
# reader methods
|
20
|
+
Class.new(Configuration).new
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def configure
|
25
|
+
yield config
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def config
|
30
|
+
self.class.config
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
class Configuration #:nodoc:
|
36
|
+
def initialize
|
37
|
+
reset!
|
38
|
+
end
|
39
|
+
|
40
|
+
# Set a configuration option.
|
41
|
+
#
|
42
|
+
# @example
|
43
|
+
# set :logger, Yell.new($stdout)
|
44
|
+
# set :logger, -> { Yell.new($stdout) }
|
45
|
+
def set(key, value = nil, &block)
|
46
|
+
@settings[key] = !block.nil? ? block : value
|
47
|
+
end
|
48
|
+
|
49
|
+
# Get a configuration option. It will be evalued of the first time
|
50
|
+
# of calling.
|
51
|
+
#
|
52
|
+
# @example
|
53
|
+
# get :logger
|
54
|
+
def get(key)
|
55
|
+
if (value = @settings[key]).is_a?(Proc)
|
56
|
+
@settings[key] = value.call
|
57
|
+
end
|
58
|
+
|
59
|
+
@settings[key]
|
60
|
+
end
|
61
|
+
|
62
|
+
# @private
|
63
|
+
def reset!
|
64
|
+
@settings = {}
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,215 @@
|
|
1
|
+
require 'active_support/concern'
|
2
|
+
|
3
|
+
require File.dirname(__FILE__) + '/configurable'
|
4
|
+
require File.dirname(__FILE__) + '/consumer/launcher'
|
5
|
+
require File.dirname(__FILE__) + '/consumer/session'
|
6
|
+
require File.dirname(__FILE__) + '/consumer/collection'
|
7
|
+
require File.dirname(__FILE__) + '/consumer/entity'
|
8
|
+
|
9
|
+
module Cottontail
|
10
|
+
# The Cottontail::Consumer is the module for receiving
|
11
|
+
# asynchronous AMQP messages.
|
12
|
+
#
|
13
|
+
# @example A basic worker
|
14
|
+
# class Worker
|
15
|
+
# include Cottontail::Consumer
|
16
|
+
#
|
17
|
+
# session ENV['RABBITMQ_URL'] do |worker, session|
|
18
|
+
# channel = session.create_channel
|
19
|
+
#
|
20
|
+
# queue = channel.queue('', durable: true)
|
21
|
+
# worker.subscribe(queue, exclusive: true, ack: false)
|
22
|
+
# end
|
23
|
+
#
|
24
|
+
# consume do |delivery_info, properties, payload|
|
25
|
+
# logger.info payload.inspect
|
26
|
+
# end
|
27
|
+
# end
|
28
|
+
#
|
29
|
+
# @example More custom worker
|
30
|
+
# class Worker
|
31
|
+
# include Cottontail::Consumer
|
32
|
+
#
|
33
|
+
# session ENV['RABBITMQ_URL'] do |worker, session|
|
34
|
+
# # You always need a separate channel
|
35
|
+
# channel = session.create_channel
|
36
|
+
#
|
37
|
+
# # Creates a `topic` exchange ('cottontail-exchange'), binds a
|
38
|
+
# # queue ('cottontail-queue') to it and listens to any possible
|
39
|
+
# # routing key ('#').
|
40
|
+
# exchange = channel.topic('cottontail-exchange')
|
41
|
+
# queue = channel.queue('cottontail-queue', durable: true)
|
42
|
+
# .bind(exchange, routing_key: '#')
|
43
|
+
#
|
44
|
+
# # Now you need to subscribe the worker instance to this queue.
|
45
|
+
# worker.subscribe(queue, exclusive: true, ack: false)
|
46
|
+
# end
|
47
|
+
#
|
48
|
+
# consume 'custom-route' do |delivery_info, properties, payload|
|
49
|
+
# logger.info "routing_key: 'custom-route' | #{payload.inspect}"
|
50
|
+
# end
|
51
|
+
#
|
52
|
+
# consume do |delivery_info, properties, payload|
|
53
|
+
# logger.info "any routing key | #{payload.inspect}"
|
54
|
+
# end
|
55
|
+
# end
|
56
|
+
module Consumer
|
57
|
+
extend ActiveSupport::Concern
|
58
|
+
|
59
|
+
included do
|
60
|
+
include Cottontail::Configurable
|
61
|
+
|
62
|
+
# default settings
|
63
|
+
set :consumables, -> { Cottontail::Consumer::Collection.new }
|
64
|
+
set :session, -> { [nil, -> {}] }
|
65
|
+
set :logger, -> { Cottontail.get(:logger) }
|
66
|
+
end
|
67
|
+
|
68
|
+
module ClassMethods #:nodoc:
|
69
|
+
# Set the Bunny session.
|
70
|
+
#
|
71
|
+
# You are required to setup a standard Bunny session as you would
|
72
|
+
# when using Bunny directly. This enables you to be configurable to
|
73
|
+
# the maximum extend.
|
74
|
+
#
|
75
|
+
# @example Simple Bunny::Session
|
76
|
+
# session ENV['RABBITMQ_URL'] do |session, worker|
|
77
|
+
# channel = session.create_channel
|
78
|
+
#
|
79
|
+
# queue = channel.queue('MyAwesomeQueue', durable: true)
|
80
|
+
# worker.subscribe(queue, exclusive: true, ack: false)
|
81
|
+
# end
|
82
|
+
#
|
83
|
+
# @example Subscribe to multiple queues
|
84
|
+
# session ENV['RABBITMQ_URL'] do |session, worker|
|
85
|
+
# channel = session.create_channel
|
86
|
+
#
|
87
|
+
# queue_a = channel.queue('queue_a', durable: true)
|
88
|
+
# worker.subscribe(queue_a, exclusive: true, ack: false)
|
89
|
+
#
|
90
|
+
# queue_b = channel.queue('queue_b', durable: true)
|
91
|
+
# worker.subscribe(queue_b, exclusive: true, ack: false)
|
92
|
+
# end
|
93
|
+
def session(options = nil, &block)
|
94
|
+
set :session, [options, block]
|
95
|
+
end
|
96
|
+
|
97
|
+
# Method for consuming messages.
|
98
|
+
#
|
99
|
+
# When `:any` is provided as parameter, all messages will be routed to
|
100
|
+
# this block. This is the default.
|
101
|
+
#
|
102
|
+
# @example By routing key
|
103
|
+
# consume route: 'message.sent' do |delivery_info, properties, payload|
|
104
|
+
# # stuff to do
|
105
|
+
# end
|
106
|
+
#
|
107
|
+
# # you can also use a shortcut for this
|
108
|
+
# consume "message.sent" do |delivery_info, properties, payload|
|
109
|
+
# # stuff to do
|
110
|
+
# end
|
111
|
+
#
|
112
|
+
# @example By multiple routing keys
|
113
|
+
# consume route: ['message.sent', 'message.read'] do |delivery_info, properties, payload|
|
114
|
+
# # stuff to do
|
115
|
+
# end
|
116
|
+
#
|
117
|
+
# # you can also use a shortcut for this
|
118
|
+
# consume ["message.sent", "message.read"] do |delivery_info, properties, payload|
|
119
|
+
# # stuff to do
|
120
|
+
# end
|
121
|
+
#
|
122
|
+
# @example Scoped to a specific queue
|
123
|
+
# consume route: 'message.sent', queue: 'chats' do |delivery_info, properties, payload|
|
124
|
+
# # do stuff
|
125
|
+
# end
|
126
|
+
#
|
127
|
+
# @example By message type (not yet implemented)
|
128
|
+
# consume type: 'ChatMessage' do |delivery_info, properties, payload|
|
129
|
+
# # stuff to do
|
130
|
+
# end
|
131
|
+
#
|
132
|
+
# @example By multiple message types (not yet implemented)
|
133
|
+
# consume type: ['ChatMessage', 'PushMessage'] do |delivery_info, properties, payload|
|
134
|
+
# # stuff to do
|
135
|
+
# end
|
136
|
+
#
|
137
|
+
def consume(route = {}, options = {}, &block)
|
138
|
+
options =
|
139
|
+
if route.is_a?(Hash)
|
140
|
+
route
|
141
|
+
else
|
142
|
+
{ route: route }
|
143
|
+
end.merge(options)
|
144
|
+
|
145
|
+
get(:consumables).push(
|
146
|
+
Cottontail::Consumer::Entity.new(options, &block)
|
147
|
+
)
|
148
|
+
end
|
149
|
+
|
150
|
+
# Conveniently start the consumer
|
151
|
+
#
|
152
|
+
# @example Since setup
|
153
|
+
# class Worker
|
154
|
+
# include Cottontail::Consumer
|
155
|
+
#
|
156
|
+
# # ... configuration ...
|
157
|
+
# end
|
158
|
+
#
|
159
|
+
# Worker.start
|
160
|
+
def start(blocking = true)
|
161
|
+
new.start(blocking)
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
def initialize
|
166
|
+
@__launcher__ = Cottontail::Consumer::Launcher.new(self)
|
167
|
+
@__session__ = Cottontail::Consumer::Session.new(self)
|
168
|
+
|
169
|
+
logger.debug '[Cottontail] initialized'
|
170
|
+
end
|
171
|
+
|
172
|
+
def start(blocking = true)
|
173
|
+
logger.info '[Cottontail] starting up'
|
174
|
+
|
175
|
+
@__session__.start
|
176
|
+
@__launcher__.start if blocking
|
177
|
+
end
|
178
|
+
|
179
|
+
def stop
|
180
|
+
logger.info '[Cottontail] shutting down'
|
181
|
+
|
182
|
+
# @__launcher__.stop
|
183
|
+
@__session__.stop
|
184
|
+
end
|
185
|
+
|
186
|
+
# @private
|
187
|
+
def subscribe(queue, options)
|
188
|
+
queue.subscribe(options) do |delivery_info, properties, payload|
|
189
|
+
consume(delivery_info, properties, payload)
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
private
|
194
|
+
|
195
|
+
def consume(delivery_info, properties, payload)
|
196
|
+
consumable = consumables.find(delivery_info, properties, payload)
|
197
|
+
|
198
|
+
if consumable.nil?
|
199
|
+
logger.error '[Cottontail] Could not consume message'
|
200
|
+
else
|
201
|
+
consumable.exec(self, delivery_info, properties, payload)
|
202
|
+
end
|
203
|
+
rescue => exception
|
204
|
+
logger.error exception
|
205
|
+
end
|
206
|
+
|
207
|
+
def consumables
|
208
|
+
config.get(:consumables)
|
209
|
+
end
|
210
|
+
|
211
|
+
def logger
|
212
|
+
config.get(:logger)
|
213
|
+
end
|
214
|
+
end
|
215
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
module Cottontail #:nodoc:
|
2
|
+
module Consumer #:nodoc:
|
3
|
+
# Holds the collection of entities
|
4
|
+
class Collection
|
5
|
+
def initialize
|
6
|
+
@items = []
|
7
|
+
end
|
8
|
+
|
9
|
+
# Pushes entity to the list and sorts it
|
10
|
+
def push(entity)
|
11
|
+
@items.push(entity).sort!
|
12
|
+
end
|
13
|
+
|
14
|
+
# {exchange: 'exchange', queue: 'queue', route: 'route'}
|
15
|
+
# {exchange: 'exchange', queue: 'queue', route: :any}
|
16
|
+
# {exchange: 'exchange', queue: :any, route: 'route'}
|
17
|
+
# {exchange: 'exchange', queue: :any, route: :any}
|
18
|
+
# {exchange: :any, queue: 'queue'}
|
19
|
+
# {exchange: :any, queue: :any}
|
20
|
+
def find(delivery_info, _properties = nil, _payload = nil)
|
21
|
+
@items
|
22
|
+
.select { |e| e.matches?(:exchange, delivery_info.exchange) }
|
23
|
+
.select { |e| e.matches?(:queue, delivery_info.consumer.queue.name) }
|
24
|
+
.find { |e| e.matches?(:route, delivery_info.routing_key) }
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
module Cottontail #:nodoc:
|
2
|
+
module Consumer #:nodoc:
|
3
|
+
class Entity #:nodoc:
|
4
|
+
include Comparable
|
5
|
+
|
6
|
+
VALID_KEYS = [:exchange, :queue, :route]
|
7
|
+
|
8
|
+
def initialize(options = {}, &block)
|
9
|
+
@options = options.keep_if { |k, _| VALID_KEYS.include?(k) }
|
10
|
+
@block = block
|
11
|
+
end
|
12
|
+
|
13
|
+
def matches?(key, value)
|
14
|
+
[value, nil, :any].include?(@options[key])
|
15
|
+
end
|
16
|
+
|
17
|
+
def exec(object, *args)
|
18
|
+
object.instance_exec(*args, &@block)
|
19
|
+
end
|
20
|
+
|
21
|
+
protected
|
22
|
+
|
23
|
+
def <=>(other)
|
24
|
+
comparables <=> other.comparables
|
25
|
+
end
|
26
|
+
|
27
|
+
def comparables
|
28
|
+
VALID_KEYS.map { |key| Property.new(@options[key] || '') }
|
29
|
+
end
|
30
|
+
|
31
|
+
class Property < String #:nodoc:
|
32
|
+
protected
|
33
|
+
|
34
|
+
def <=>(other)
|
35
|
+
case
|
36
|
+
when length == 0 && other.length == 0 then 0
|
37
|
+
when length == 0 then 1
|
38
|
+
when other.length == 0 then -1
|
39
|
+
else super
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
require 'thread'
|
2
|
+
|
3
|
+
module Cottontail #:nodoc:
|
4
|
+
module Consumer #:nodoc:
|
5
|
+
class Launcher #:nodoc:
|
6
|
+
SIGNALS = [:QUIT, :TERM, :INT]
|
7
|
+
|
8
|
+
def initialize(consumer)
|
9
|
+
@consumer = consumer
|
10
|
+
@launcher = nil
|
11
|
+
end
|
12
|
+
|
13
|
+
def start
|
14
|
+
stop unless @launcher.nil?
|
15
|
+
|
16
|
+
SIGNALS.each do |signal|
|
17
|
+
Signal.trap(signal) { Thread.new { stop } }
|
18
|
+
end
|
19
|
+
|
20
|
+
@launcher = Thread.new { sleep }
|
21
|
+
@launcher.join
|
22
|
+
end
|
23
|
+
|
24
|
+
def stop
|
25
|
+
@consumer.stop if @consumer.respond_to?(:stop)
|
26
|
+
|
27
|
+
@launcher.kill if @launcher.respond_to?(:kill)
|
28
|
+
@launcher = nil
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require 'bunny'
|
2
|
+
|
3
|
+
module Cottontail #:nodoc:
|
4
|
+
module Consumer #:nodoc:
|
5
|
+
class Session #:nodoc:
|
6
|
+
def initialize(consumer)
|
7
|
+
@consumer = consumer
|
8
|
+
@options, @block = @consumer.config.get(:session)
|
9
|
+
@session = nil
|
10
|
+
end
|
11
|
+
|
12
|
+
def start
|
13
|
+
stop unless @session.nil?
|
14
|
+
|
15
|
+
@session = Bunny.new(@options)
|
16
|
+
@session.start
|
17
|
+
|
18
|
+
@consumer.instance_exec(@consumer, @session, &@block)
|
19
|
+
end
|
20
|
+
|
21
|
+
def stop
|
22
|
+
@session.stop if @session.respond_to?(:stop)
|
23
|
+
@session = nil
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
data/lib/cottontail/version.rb
CHANGED
@@ -1,3 +1,3 @@
|
|
1
|
-
module Cottontail
|
2
|
-
VERSION =
|
1
|
+
module Cottontail #:nodoc:
|
2
|
+
VERSION = '2.0.0.pre.1'
|
3
3
|
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
RSpec.describe Cottontail::Configurable do
|
4
|
+
class ConfigurationFactory #:nodoc:
|
5
|
+
include Cottontail::Configurable
|
6
|
+
end
|
7
|
+
|
8
|
+
let(:config) { ConfigurationFactory.config }
|
9
|
+
before { config.reset! }
|
10
|
+
|
11
|
+
it 'responds to :get' do
|
12
|
+
expect(config).to respond_to(:get)
|
13
|
+
end
|
14
|
+
|
15
|
+
it 'responds to :set' do
|
16
|
+
expect(config).to respond_to(:set)
|
17
|
+
end
|
18
|
+
|
19
|
+
context 'String' do
|
20
|
+
let(:value) { 'value' }
|
21
|
+
before { config.set(:key, value) }
|
22
|
+
|
23
|
+
it 'returns the correct value' do
|
24
|
+
expect(config.get(:key)).to eq(value)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
context 'Proc' do
|
29
|
+
let(:value) { -> { 'value' } }
|
30
|
+
before { config.set(:key, value) }
|
31
|
+
|
32
|
+
it 'returns the correct value' do
|
33
|
+
expect(config.get(:key)).to eq(value.call)
|
34
|
+
end
|
35
|
+
|
36
|
+
it 'calls only once' do
|
37
|
+
v = config.get(:key)
|
38
|
+
expect(config.get(:key)).to equal(v)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|