mb-minion 0.2.0

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,177 @@
1
+ require 'pp'
2
+ # encoding: utf-8
3
+ module Minion
4
+ class Handler
5
+ attr_reader :queue_name, :block, :batch_size, :wait
6
+
7
+ # Executes the handler. Will subscribe to a queue or unsubscribe to it
8
+ # depending on the conditions.
9
+ #
10
+ # @example Execute the handler.
11
+ # handler.execute
12
+ def execute
13
+ subscribable? ? subscribe : unsubscribe
14
+ end
15
+
16
+ # Instantiate the new handler. Takes a queue name and optional lambda to
17
+ # determine conditionally if a queue is subscribable.
18
+ #
19
+ # @example Create the new handler.
20
+ # Handler.new("minion.test")
21
+ #
22
+ # @param [ String ] queue_name The name of the queue.
23
+ # @param [ Hash ]
24
+ # @option options [ lambda ] :when The block for conditionally subscribing.
25
+ # @option options [ fixnum ] :batch_size The number of elements per batch
26
+ # @option options [ symbol ] :map The type of map operation: fanout or reduce
27
+ def initialize(queue_name, block, options = {})
28
+ @queue_name, @block = queue_name, block
29
+ @subscribable = options[:when]
30
+ @batch_size = options[:batch_size]
31
+ @wait = options[:wait] || false
32
+ raise ArgumentError, "wait parameter makes no sense without a batch_size" if (@wait && ! @batch_size)
33
+ end
34
+
35
+ private
36
+
37
+ # Returns true if the handler is already subscribed to the queue.
38
+ #
39
+ # @example Is the handler running?
40
+ # handler.running?
41
+ #
42
+ # @return [ true, false ] Is the handler running?
43
+ def running?
44
+ !!@running
45
+ end
46
+
47
+ # Determines if the queue is able to be subscribed to.
48
+ #
49
+ # @example Is the queue subscribable?
50
+ # handler.subscribable?
51
+ def subscribable?
52
+ @subscribable ? @subscribable.call : true
53
+ end
54
+
55
+ # Subscribe to the queue. Will do so if the handler is not already
56
+ # subscribed.
57
+ #
58
+ # @example Subscribe to the queue.
59
+ # handler.subscribe
60
+ def subscribe
61
+ unless running?
62
+ Minion.info("Subscribing to #{queue_name}")
63
+ chan = AMQP::Channel.new
64
+ chan.prefetch(1)
65
+ queue = chan.queue(queue_name, :durable => true, :auto_delete => false)
66
+ if batch_size && batch_size > 1
67
+ process_batch(queue)
68
+ else
69
+ process_single_message(queue)
70
+ end
71
+ @running = true
72
+ end
73
+
74
+ end
75
+
76
+ # Process a multiple messages from a queue as a batch
77
+ #
78
+ # @example Subscribe to the queue.
79
+ # handler.process_batch(queue)
80
+ #
81
+ # @param [ AMQP::Queue ]
82
+ #
83
+ def process_batch(queue)
84
+ # Our batch message will have an array for it's content
85
+ msg = Message.new
86
+ queue.subscribe(:ack => true) do |h, m|
87
+ return if AMQP.closing?
88
+ Minion.info("Received: #{queue_name}:#{m}, #{h}")
89
+ args = decode(m)
90
+
91
+ # All messages in the batch get the callbacks from
92
+ # the first message. This is why when using chained
93
+ # callbacks on batches, you always have to use the
94
+ # same combo of callback-queues!
95
+ msg.callbacks = args['callbacks']
96
+ msg.batch << args['content']
97
+ h.ack # acks are useless in batch-mode.
98
+ # You'll have to make sure you requeue manually
99
+ if (msg.batch.size == batch_size) || process_anyway?
100
+ msg.content = block.call(msg)
101
+ msg.callback
102
+ msg.batch.clear
103
+ end
104
+ Minion.execute_handlers
105
+ end
106
+ rescue Object => e
107
+ Minion.alert(e)
108
+ end
109
+
110
+ # Process a single message from a queue
111
+ #
112
+ # @example Subscribe to the queue.
113
+ # handler.process_single_message(queue)
114
+ #
115
+ # @param [ AMQP::Queue ]
116
+ #
117
+ def process_single_message(queue)
118
+ queue.subscribe(:ack => true) do |h, m|
119
+ return if AMQP.closing?
120
+ Minion.info("Received: #{queue_name}:#{m}, #{h}")
121
+ msg = Message.new(m, h)
122
+ msg.content = block.call(msg)
123
+ h.ack
124
+ msg.callback
125
+ Minion.execute_handlers
126
+ end
127
+ rescue Object => e
128
+ Minion.alert(e)
129
+ end
130
+
131
+ # Get a string respresentation of the handler.
132
+ #
133
+ # @example Print out the string.
134
+ # handler.to_s
135
+ #
136
+ # @return [ String ] The handler as a string.
137
+ def to_s
138
+ "<handler queue_name=#{@queue_name} on=#{@on}>"
139
+ end
140
+
141
+ # Unsubscribe from the queue.
142
+ #
143
+ # @example Unsubscribe from the queue.
144
+ # handler.unsubscribe
145
+ def unsubscribe
146
+ Minion.info("Unsubscribing to #{queue_name}")
147
+ AMQP::Channel.new.queue(queue_name, :durable => true, :auto_delete => false).unsubscribe
148
+ @running = false
149
+ end
150
+
151
+ private
152
+
153
+ def decode(json)
154
+ defined?(ActiveSupport::JSON) ?
155
+ ActiveSupport::JSON.decode(json) : JSON.load(json)
156
+ end
157
+
158
+ # Determine if we should process a batch even if
159
+ # we haven't reached the batch_size
160
+ #
161
+ # @return [ Boolean ] if we should go ahead and process the batch
162
+ def process_anyway?
163
+ return false if Minion.message_count(queue_name) != 0 # there's work to be done!
164
+ case wait
165
+ when true then false # Wait indefinitely
166
+ when false then true # Don't wait at all
167
+ when Numeric
168
+ (0..wait).each do |i|
169
+ return false if Minion.message_count(queue_name) != 0
170
+ sleep 1
171
+ end
172
+ # Wait this many, then if the queue is still empty, go ahead
173
+ Minion.message_count(queue_name) == 0
174
+ end
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,58 @@
1
+ require File.join 'active_support', 'core_ext', 'module', 'delegation'
2
+
3
+ module Minion
4
+ class Message
5
+ attr_accessor :content, :callbacks, :headers, :batch
6
+ delegate :clear, :map, :each, :size, :count, :[], :each_with_index, :cycle, :shuffle, :to => :content
7
+
8
+ def initialize json="{}", header=nil
9
+ data = decode(json)
10
+ @headers = [header]
11
+ @callbacks = data['callbacks']
12
+ @content = data['content']
13
+ @batch = data['batch'] || []
14
+ end
15
+
16
+ def << data
17
+ @content << data
18
+ end
19
+
20
+ # Enqueue a job for the next callback in the chain
21
+ #
22
+ # @return void
23
+ def callback
24
+ headers.clear
25
+ if callbacks and not callbacks.empty?
26
+ queue_name = callbacks.shift
27
+ Minion.enqueue(queue_name, as_json)
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ # Decode the json string into a hash.
34
+ #
35
+ # @example Decode the json.
36
+ # decode("{ field : "value" }")
37
+ #
38
+ # @param [ String ] json The json string.
39
+ #
40
+ # @return [ Hash ] The json as a hash.
41
+ def decode(json)
42
+ defined?(ActiveSupport::JSON) ?
43
+ ActiveSupport::JSON.decode(json) : JSON.load(json)
44
+ end
45
+
46
+ def as_json
47
+ { 'callbacks' => callbacks,
48
+ 'headers' => headers,
49
+ 'content' => content
50
+ }
51
+ end
52
+
53
+ def to_json
54
+ JSON.dump(as_json || {}).force_encoding("ISO-8859-1")
55
+ end
56
+
57
+ end
58
+ end
@@ -0,0 +1,4 @@
1
+ # encoding: utf-8
2
+ module Minion #:nodoc
3
+ VERSION = "0.2.0"
4
+ end
@@ -0,0 +1,147 @@
1
+ require "spec_helper"
2
+
3
+ describe Minion::Handler do
4
+
5
+ before(:all) do
6
+ Minion.logger {}
7
+ end
8
+
9
+ let(:channel) do
10
+ stub.quacks_like(AMQP::Channel.allocate)
11
+ end
12
+
13
+ let(:queue) do
14
+ stub.quacks_like(AMQP::Queue.allocate)
15
+ end
16
+
17
+ describe "#execute" do
18
+
19
+ before do
20
+ channel.expects(:prefetch).at_most_once
21
+ AMQP::Channel.stubs(:new).returns(channel)
22
+ end
23
+
24
+ context "when the queue is subscribable" do
25
+
26
+ let(:handler) do
27
+ described_class.new("minion.test", lambda{ true })
28
+ end
29
+
30
+ context "when the handler is not already running" do
31
+
32
+ before do
33
+ channel.expects(:queue).with(
34
+ "minion.test", :durable => true, :auto_delete => false
35
+ ).returns(queue)
36
+ end
37
+
38
+ it "subscribes to the queue" do
39
+ queue.expects(:subscribe)
40
+ handler.execute
41
+ end
42
+ end
43
+
44
+ context "when the handler is running" do
45
+
46
+ before do
47
+ handler.instance_variable_set(:@running, true)
48
+ end
49
+
50
+ it "does not subscribe again" do
51
+ channel.expects(:queue).never
52
+ handler.execute
53
+ end
54
+ end
55
+ end
56
+
57
+ context "when the queue is not subscribable" do
58
+
59
+ let(:handler) do
60
+ described_class.new("minion.test", lambda{ true }, :when => lambda{ false })
61
+ end
62
+
63
+ before do
64
+ channel.expects(:queue).with(
65
+ "minion.test", :durable => true, :auto_delete => false
66
+ ).returns(queue)
67
+ end
68
+
69
+ it "unsubscribes from the queue" do
70
+ queue.expects(:unsubscribe)
71
+ handler.execute
72
+ end
73
+ end
74
+
75
+ context "when wait parameter is specified" do
76
+ it "should raise an error" do
77
+ expect do
78
+ described_class.new("minion.test", lambda{ |batch| {"content" => true} }, :wait => true)
79
+ end.to raise_error(ArgumentError)
80
+ end
81
+ end
82
+
83
+ context "when a batch size is specified" do
84
+ let(:block) do
85
+ lambda{ |batch| {"content" => true} }
86
+ end
87
+
88
+ let(:handler) do
89
+ described_class.new("minion.test", block, :batch_size => 10)
90
+ end
91
+
92
+ let(:header) do
93
+ stub.quacks_like(AMQP::Header.allocate)
94
+ end
95
+
96
+ let(:serialized) do
97
+ '{"content":{"field":"value"}}'
98
+ end
99
+
100
+ let(:batch) do
101
+ [header, serialized] * 10
102
+ end
103
+
104
+ before do
105
+ queue.expects(:subscribe).multiple_yields(batch)
106
+ channel.expects(:queue).with(
107
+ "minion.test", :durable => true, :auto_delete => false
108
+ ).returns(queue)
109
+ Minion.expects(:execute_handlers)
110
+ header.expects(:ack)
111
+ end
112
+
113
+ it "calls once for 10 messages" do
114
+ block.expects(:call).once
115
+ handler.execute
116
+ end
117
+
118
+ context "when wait parameter is specified" do
119
+ let(:handler) do
120
+ described_class.new("minion.test", block, :batch_size => 10, :wait => true)
121
+ end
122
+
123
+ it "doesn't call for 9 messages" do
124
+ block.expects(:call).never
125
+ handler.execute
126
+ end
127
+ end
128
+
129
+ context "when wait paramter is numeric" do
130
+ let(:batch) do
131
+ [header, serialized] * 9
132
+ end
133
+
134
+ let(:handler) do
135
+ described_class.new("minion.test", block, :batch_size => 10, :wait => 2)
136
+ end
137
+
138
+ it "calls once for 9 messages after waiting a bit" do
139
+ block.expects(:call).once
140
+ handler.execute
141
+ end
142
+ end
143
+ end
144
+
145
+
146
+ end
147
+ end
@@ -0,0 +1,38 @@
1
+ require "spec_helper"
2
+
3
+ describe Minion::Message do
4
+ let(:header) do
5
+ stub.quacks_like(AMQP::Header.allocate)
6
+ end
7
+
8
+ let(:serialized) do
9
+ '{"content":{"field":"value"}, "callbacks":["minion.second", "minion.third"]}'
10
+ end
11
+
12
+ subject do
13
+ Minion::Message.new(serialized, header)
14
+ end
15
+
16
+ its(:content){ should eql({"field"=>"value"}) }
17
+ its(:callbacks){ should eql ["minion.second", "minion.third"] }
18
+ its(:headers){ should eql [header]}
19
+
20
+ context "when callback is executed" do
21
+
22
+ let(:data) do
23
+ {'callbacks' => ['minion.third'], 'headers' => [], 'content' => {'field' => 'value'}}
24
+ end
25
+
26
+ before do
27
+ subject.headers.clear
28
+ subject.headers.expects(:clear)
29
+ end
30
+
31
+ it "should enqueue the next job" do
32
+ Minion.expects(:enqueue).with('minion.second', data)
33
+ subject.callback
34
+ end
35
+
36
+ end
37
+
38
+ end
@@ -0,0 +1,238 @@
1
+ # encoding: utf-8
2
+ require "spec_helper"
3
+
4
+ describe Minion do
5
+
6
+ let(:bunny) do
7
+ Bunny.new(Minion.config).tap do |bunny|
8
+ bunny.start
9
+ end
10
+ end
11
+
12
+ before(:all) do
13
+ Minion.logger {}
14
+ end
15
+
16
+ describe ".alert" do
17
+
18
+ context "when an error handler is provided" do
19
+
20
+ let(:error) do
21
+ RuntimeError.new("testing")
22
+ end
23
+
24
+ before do
25
+ Minion.error do |error|
26
+ error.message
27
+ end
28
+ end
29
+
30
+ after do
31
+ Minion.error
32
+ end
33
+
34
+ it "delegates to the handler" do
35
+ Minion.alert(error).should == "testing"
36
+ end
37
+ end
38
+
39
+ context "when an error handler is not provided" do
40
+
41
+ let(:error) do
42
+ RuntimeError.new("testing")
43
+ end
44
+
45
+ it "raises the error" do
46
+ expect { Minion.alert(error) }.to raise_error(RuntimeError)
47
+ end
48
+ end
49
+ end
50
+
51
+ describe ".enqueue" do
52
+
53
+ let(:queue) do
54
+ bunny.queue("minion.test", :durable => true, :auto_delete => false)
55
+ end
56
+
57
+ before do
58
+ queue.purge
59
+ end
60
+
61
+ context "when provided a string" do
62
+
63
+ context "when no data is provided" do
64
+
65
+ before do
66
+ Minion.enqueue("minion.test")
67
+ end
68
+
69
+ let(:message) do
70
+ JSON.parse(queue.pop[:payload])
71
+ end
72
+
73
+ it "adds empty json to the queue" do
74
+ message.should == {"content" => {}}
75
+ end
76
+ end
77
+
78
+ context "when nil data is provided" do
79
+
80
+ before do
81
+ Minion.enqueue("minion.test", nil)
82
+ end
83
+
84
+ let(:message) do
85
+ JSON.parse(queue.pop[:payload])
86
+ end
87
+
88
+ it "adds empty json to the queue" do
89
+ message.should == {"content" => nil}
90
+ end
91
+ end
92
+
93
+ context "when data is provided" do
94
+
95
+ context "when the data has no special characters" do
96
+
97
+ let(:data) do
98
+ {"content"=>{"field"=>"value"}}
99
+ end
100
+
101
+ before do
102
+ Minion.enqueue("minion.test", data)
103
+ end
104
+
105
+ let(:message) do
106
+ JSON.parse(queue.pop[:payload])
107
+ end
108
+
109
+ it "adds the json to the queue" do
110
+ message.should == data
111
+ end
112
+ end
113
+
114
+ context "when the data contains special characters" do
115
+
116
+ let(:data) do
117
+ {"content"=>{"field"=>"öüäßÖÜÄ"}}
118
+ end
119
+
120
+ before do
121
+ Minion.enqueue("minion.test", data)
122
+ end
123
+
124
+ let(:message) do
125
+ JSON.parse(queue.pop[:payload])
126
+ end
127
+
128
+ it "adds the json to the queue" do
129
+ message.should == data
130
+ end
131
+ end
132
+ end
133
+ end
134
+
135
+ context "when provided a nil queue" do
136
+
137
+ it "raises an error" do
138
+ expect { Minion.enqueue(nil, {}) }.to raise_error(RuntimeError)
139
+ end
140
+ end
141
+
142
+ context "when passed an array" do
143
+
144
+ let(:first) do
145
+ bunny.queue("minion.first", :durable => true, :auto_delete => false)
146
+ end
147
+
148
+ before do
149
+ first.purge
150
+ end
151
+
152
+ context "when the array is empty" do
153
+
154
+ it "raises an error" do
155
+ expect { Minion.enqueue([], {}) }.to raise_error(RuntimeError)
156
+ end
157
+ end
158
+
159
+ context "when the array has queue names" do
160
+
161
+ let(:data) do
162
+ {"field"=>"value"}
163
+ end
164
+
165
+ let(:serialized) do
166
+ {"content"=>{"field"=>"value"}, "callbacks"=>["minion.second", "minion.third"]}
167
+ end
168
+
169
+ before do
170
+ Minion.enqueue([ "minion.first", "minion.second", "minion.third" ], data)
171
+ end
172
+
173
+ let(:message) do
174
+ JSON.parse(first.pop[:payload])
175
+ end
176
+
177
+ it "adds the serialized data to the first queue" do
178
+ message.should == serialized
179
+ end
180
+
181
+ context "when the data has already been serialized" do
182
+ before do
183
+ Minion.enqueue([ "minion.first", "minion.second", "minion.third" ], serialized)
184
+ end
185
+
186
+ let(:message) do
187
+ JSON.parse(first.pop[:payload])
188
+ end
189
+
190
+ it "adds has the same serialized data in the first queue" do
191
+ message.should == serialized
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end
197
+
198
+
199
+ describe ".error_handling" do
200
+
201
+ context "when nothing has been defined" do
202
+
203
+ it "returns nil" do
204
+ Minion.send(:error_handling).should be_nil
205
+ end
206
+ end
207
+ end
208
+
209
+ describe ".error" do
210
+
211
+ let(:block) do
212
+ lambda{ "testing" }
213
+ end
214
+
215
+ before do
216
+ Minion.error(&block)
217
+ end
218
+
219
+ it "sets the error handling to the provided block" do
220
+ Minion.send(:error_handling).should == block
221
+ end
222
+ end
223
+
224
+ describe ".info" do
225
+
226
+ let(:block) do
227
+ lambda{ |message| message }
228
+ end
229
+
230
+ before do
231
+ Minion.logger(&block)
232
+ end
233
+
234
+ it "delegates the logging to the provided block" do
235
+ Minion.info("testing").should == "testing"
236
+ end
237
+ end
238
+ end